Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions basic-memory-git-sync/src/basic_memory_git_sync/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions basic-memory-git-sync/tests/unit/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down