From 534f1969e9c59b3e08e23fbea99fc637045a709d Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 25 Sep 2026 16:29:19 +0200 Subject: [PATCH] fix(memory): clone the brain projection on a fresh install On a first `./stack up memory` the brain projection was never cloned. `on_start` creates the wiki's media mountpoint inside `~/famstack-data/memory/brain` before the containers start, and the `family/brain` repo only exists once they are up, so `git clone` met a non-empty directory and refused. The step still printed a success mark, and the curator waited for the projection forever: no wiki page was regenerated and no diary was compiled. The brain clone now turns an existing directory into a working copy in place: tracked files take the repository's version, untracked ones such as the mountpoint stay. Brain is a projection that can be rebuilt, so that is safe there; the vault keeps the strict clone. A failed vault or brain clone is now reported as a warning. Fixes: 03e1708 ("feat(memory): keep the original file behind a wiki page (#92)") Closes: #148 --- stacklets/memory/hooks/on_start_ready.py | 4 +- stacklets/memory/lib.py | 44 ++++++++++- .../stacklets/test_memory_install_seeds.py | 4 +- .../stacklets/test_memory_token_repair.py | 5 +- tests/unit/stacklets/test_memory_vault.py | 79 +++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/stacklets/memory/hooks/on_start_ready.py b/stacklets/memory/hooks/on_start_ready.py index e7ed1e94..5f432364 100644 --- a/stacklets/memory/hooks/on_start_ready.py +++ b/stacklets/memory/hooks/on_start_ready.py @@ -98,7 +98,7 @@ def remote_for(tok: str) -> str: if ensure_vault_cloned(vault, remote): ctx.step(f"Memory vault cloned to {vault}") else: - ctx.step(f"Memory vault clone failed at {vault}") + ctx.warn(f"Memory vault clone failed at {vault}") return else: if remote: @@ -140,4 +140,4 @@ def remote_for(tok: str) -> str: if result.get("cloned_brain"): ctx.step(f"Memory: cloned brain projection to {brain}") elif not (brain / ".git").exists(): - ctx.step(f"Memory brain projection clone failed at {brain}") + ctx.warn(f"Memory brain projection clone failed at {brain}: the wiki and the diary will not update") diff --git a/stacklets/memory/lib.py b/stacklets/memory/lib.py index 500fb931..c060c747 100644 --- a/stacklets/memory/lib.py +++ b/stacklets/memory/lib.py @@ -32,6 +32,7 @@ import json import math import re +import shutil import subprocess import time import unicodedata @@ -277,6 +278,45 @@ def ensure_vault_cloned( return rc == 0 +def ensure_projection_cloned( + brain_path: Path, + remote_url: str, + *, + timeout: int = 60, +) -> bool: + """Clone the brain projection into `brain_path`, adopting what is there. + + On a first install `brain_path` is never empty by the time the brain + repo exists: `on_start` creates the wiki's media mountpoint inside it + before the containers start, and the repo is created after they are + up. `git clone` refuses a non-empty directory, so the directory is + turned into a working copy in place instead. Files the repo tracks + are replaced by the repo's version; untracked ones, the mountpoint + among them, stay. That is only acceptable because brain is a + projection that can be rebuilt at any time. The vault is the family's + source and keeps the strict `ensure_vault_cloned`. + + Returns True when a working copy is there afterwards. On failure the + directory is left as it was found. + """ + brain_path = Path(brain_path) + if (brain_path / ".git").exists(): + return True + if not brain_path.is_dir() or not any(brain_path.iterdir()): + return ensure_vault_cloned(brain_path, remote_url, timeout=timeout) + + staging = brain_path.with_name(brain_path.name + ".clone") + shutil.rmtree(staging, ignore_errors=True) + rc, _, _ = _git(["git", "clone", "--no-checkout", remote_url, str(staging)], timeout=timeout) + if rc == 0: + (staging / ".git").rename(brain_path / ".git") + rc, _, _ = run_git(brain_path, "reset", "--hard", "--quiet", timeout=timeout) + if rc != 0: + shutil.rmtree(brain_path / ".git", ignore_errors=True) + shutil.rmtree(staging, ignore_errors=True) + return rc == 0 + + def point_remote_at(repo_path: Path, remote_url: str, *, name: str = "origin", timeout: int = 10) -> bool: """Re-derive a working copy's remote URL, adding the remote if absent. @@ -1354,7 +1394,7 @@ def ensure_brain_projection_admin( brain_remote_url(code_url), admin_user, admin_token, ) - cloned_brain = ensure_vault_cloned(brain_path, brain_remote) and not had_brain + cloned_brain = ensure_projection_cloned(brain_path, brain_remote) and not had_brain if had_brain: # Freshly issued token, current host URL — the clone made on # an older lease with an older token gets both refreshed. @@ -2157,7 +2197,7 @@ def install_memory_to_forgejo_admin( brain_remote_url(code_url), admin_user, admin_token, ) - cloned_brain = ensure_vault_cloned(brain_path, brain_remote) + cloned_brain = ensure_projection_cloned(brain_path, brain_remote) return { "created_org": repo_state["created_org"], diff --git a/tests/unit/stacklets/test_memory_install_seeds.py b/tests/unit/stacklets/test_memory_install_seeds.py index 2d0fa550..2a569f92 100644 --- a/tests/unit/stacklets/test_memory_install_seeds.py +++ b/tests/unit/stacklets/test_memory_install_seeds.py @@ -338,12 +338,12 @@ def test_creates_seeds_and_clones_missing_brain(self, httpserver, tmp_path, monk cloned = {} - def fake_clone(path, remote): + def fake_clone(path, remote, **_): cloned["path"] = path cloned["remote"] = remote return True - monkeypatch.setattr(memory_lib, "ensure_vault_cloned", fake_clone) + monkeypatch.setattr(memory_lib, "ensure_projection_cloned", fake_clone) brain = tmp_path / "brain" result = ensure_brain_projection_admin( diff --git a/tests/unit/stacklets/test_memory_token_repair.py b/tests/unit/stacklets/test_memory_token_repair.py index 428ee00a..684fcfdb 100644 --- a/tests/unit/stacklets/test_memory_token_repair.py +++ b/tests/unit/stacklets/test_memory_token_repair.py @@ -107,7 +107,7 @@ class _RecordingCtx: A real `StackContext` wants an instance on disk, a parsed config and a Docker runtime. The hook wants four things: the data dir, the - rendered env, the secret store, and somewhere to log progress. + rendered env, the secret store, and somewhere to log progress and warnings. Standing up only those four is what lets the hook be driven end to end here, which matters because the bug this file guards lived in `run()` itself and not in any helper it calls. @@ -128,6 +128,9 @@ def secret(self, name, value=None): def step(self, message): self.steps.append(message) + def warn(self, message): + self.steps.append(message) + @pytest.fixture def forgejo_issuing_tokens(httpserver) -> str: diff --git a/tests/unit/stacklets/test_memory_vault.py b/tests/unit/stacklets/test_memory_vault.py index 2d3887b2..f787fbfa 100644 --- a/tests/unit/stacklets/test_memory_vault.py +++ b/tests/unit/stacklets/test_memory_vault.py @@ -19,6 +19,7 @@ from lib import ( # noqa: E402 authenticated_remote, + ensure_projection_cloned, ensure_vault_cloned, get_ontology, host_code_url, @@ -207,6 +208,84 @@ def test_returns_false_on_bad_remote(self, tmp_path): assert not (vault / ".git").exists() +class TestEnsureVaultClonedLeavesExistingFilesAlone: + """The vault is the family's source: never clone over files already there.""" + + def test_refuses_a_directory_that_already_holds_files(self, tmp_path, seeded_upstream): + vault = tmp_path / "memory" / "vault" + vault.mkdir(parents=True) + (vault / "notes.md").write_text("typed by hand\n") + + ok = ensure_vault_cloned(vault, str(seeded_upstream)) + + assert ok is False + assert not (vault / ".git").exists() + assert (vault / "notes.md").read_text() == "typed by hand\n" + + +# ─── ensure_projection_cloned ──────────────────────────────────────────── +# On a first install the wiki's media mountpoint (`brain/media`) is created +# before the containers start, and the brain repo only exists once they are +# up. By the time brain is cloned its directory is therefore never empty. +# Brain is a projection that can be rebuilt at any time, so the clone +# adopts the directory instead of refusing it. + +class TestEnsureProjectionCloned: + + def test_adopts_a_directory_the_mountpoint_already_created(self, tmp_path, seeded_upstream): + brain = tmp_path / "memory" / "brain" + (brain / "media").mkdir(parents=True) + (brain / "media" / "photo.png").write_bytes(b"png") + + ok = ensure_projection_cloned(brain, str(seeded_upstream)) + + assert ok is True + assert (brain / ".git").is_dir() + assert (brain / "ontology.toml").exists() + assert (brain / "media" / "photo.png").read_bytes() == b"png" + + def test_the_repository_wins_over_a_page_written_before_the_clone(self, tmp_path, seeded_upstream): + brain = tmp_path / "memory" / "brain" + brain.mkdir(parents=True) + (brain / "ontology.toml").write_text("# generated before the clone\n") + + assert ensure_projection_cloned(brain, str(seeded_upstream)) is True + + assert "simracing" in (brain / "ontology.toml").read_text() + + def test_leaves_the_working_copy_on_the_default_branch_tracking_origin(self, tmp_path, seeded_upstream): + brain = tmp_path / "memory" / "brain" + (brain / "media").mkdir(parents=True) + ensure_projection_cloned(brain, str(seeded_upstream)) + + _push_change(seeded_upstream, tmp_path, "later.md", "pushed later\n") + + assert pull_vault(brain) is True + assert (brain / "later.md").exists() + + def test_clones_normally_when_the_directory_is_missing(self, tmp_path, seeded_upstream): + brain = tmp_path / "memory" / "brain" + + assert ensure_projection_cloned(brain, str(seeded_upstream)) is True + assert (brain / "ontology.toml").exists() + + def test_idempotent_on_an_existing_working_copy(self, tmp_path, seeded_upstream): + brain = tmp_path / "memory" / "brain" + ensure_projection_cloned(brain, str(seeded_upstream)) + + assert ensure_projection_cloned(brain, str(seeded_upstream)) is True + + def test_bad_remote_leaves_the_directory_as_it_was(self, tmp_path): + brain = tmp_path / "memory" / "brain" + (brain / "media").mkdir(parents=True) + + ok = ensure_projection_cloned(brain, "/nonexistent/repo", timeout=5) + + assert ok is False + assert not (brain / ".git").exists() + assert (brain / "media").is_dir() + + # ─── pull_vault ────────────────────────────────────────────────────────── class TestPullVault: