|
9 | 9 |
|
10 | 10 | from __future__ import annotations |
11 | 11 |
|
| 12 | +import types |
| 13 | + |
12 | 14 | import pytest |
13 | 15 |
|
14 | 16 | from project_board import store |
@@ -71,6 +73,68 @@ def test_escalation_enabled_needs_two_distinct_coders(cfg, expected): |
71 | 73 | assert escalation_enabled(cfg) is expected |
72 | 74 |
|
73 | 75 |
|
| 76 | +# ── _ensure_workspace: pin to the repo's own .beads/ (no walk-up escape, #48) ──── |
| 77 | + |
| 78 | + |
| 79 | +def _ok(): |
| 80 | + return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 81 | + |
| 82 | + |
| 83 | +def _board(monkeypatch, *, db=None, repo="/repo"): |
| 84 | + """A BeadsBoard with the `br` PATH check stubbed (so __init__ passes) but the REAL |
| 85 | + _ensure_workspace intact — for exercising the workspace-pin logic directly.""" |
| 86 | + monkeypatch.setattr(store.shutil, "which", lambda *_a, **_k: "/usr/bin/br") |
| 87 | + return BeadsBoard(db=db, repo=repo) |
| 88 | + |
| 89 | + |
| 90 | +def test_ensure_workspace_noop_with_explicit_db(monkeypatch): |
| 91 | + """An explicit db_path is the hard pin — never br-init, never walk up.""" |
| 92 | + calls = [] |
| 93 | + monkeypatch.setattr(store.subprocess, "run", lambda *a, **k: calls.append(a) or _ok()) |
| 94 | + b = _board(monkeypatch, db="/somewhere/.beads/beads.db") |
| 95 | + b._ensure_workspace() |
| 96 | + assert calls == [] and b._workspace_ready # no init shelled |
| 97 | + |
| 98 | + |
| 99 | +def test_ensure_workspace_noop_when_repo_has_beads(monkeypatch): |
| 100 | + """Repo already has its own .beads/ → cwd-discovery resolves locally; no init.""" |
| 101 | + monkeypatch.setattr(store.os.path, "isdir", lambda p: p.endswith(".beads")) |
| 102 | + calls = [] |
| 103 | + monkeypatch.setattr(store.subprocess, "run", lambda *a, **k: calls.append(a) or _ok()) |
| 104 | + _board(monkeypatch)._ensure_workspace() |
| 105 | + assert calls == [] |
| 106 | + |
| 107 | + |
| 108 | +def test_ensure_workspace_br_inits_a_repo_with_no_beads(monkeypatch): |
| 109 | + """Repo with no .beads/ → `br init` it ONCE, then the pin is ready and not re-run.""" |
| 110 | + state = {"beads": False} |
| 111 | + monkeypatch.setattr(store.os.path, "isdir", lambda p: state["beads"] and p.endswith(".beads")) |
| 112 | + inits = [] |
| 113 | + |
| 114 | + def _run(cmd, **k): |
| 115 | + inits.append(cmd) |
| 116 | + state["beads"] = True # init created .beads/ |
| 117 | + return _ok() |
| 118 | + |
| 119 | + monkeypatch.setattr(store.subprocess, "run", _run) |
| 120 | + b = _board(monkeypatch, repo="/fresh") |
| 121 | + b._ensure_workspace() |
| 122 | + assert len(inits) == 1 and inits[0][:2] == [store.BR, "init"] and b._workspace_ready |
| 123 | + b._ensure_workspace() # idempotent — guarded by _workspace_ready, no second init |
| 124 | + assert len(inits) == 1 |
| 125 | + |
| 126 | + |
| 127 | +def test_ensure_workspace_raises_a_clear_error_when_init_fails(monkeypatch): |
| 128 | + """No .beads/ and `br init` fails (still none) → an actionable BoardError, NOT a |
| 129 | + silent escape to a parent db.""" |
| 130 | + monkeypatch.setattr(store.os.path, "isdir", lambda p: False) |
| 131 | + monkeypatch.setattr( |
| 132 | + store.subprocess, "run", lambda *a, **k: types.SimpleNamespace(returncode=1, stdout="", stderr="denied") |
| 133 | + ) |
| 134 | + with pytest.raises(BoardError, match="has no beads workspace"): |
| 135 | + _board(monkeypatch, repo="/ro")._ensure_workspace() |
| 136 | + |
| 137 | + |
74 | 138 | def test_next_tier_walks_then_stops_at_the_top(make_board): |
75 | 139 | b = make_board(Br()) |
76 | 140 | # Ladder: smart → reasoning → opus (fast dropped — protolabs/fast too weak). |
|
0 commit comments