dotbrain 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotbrain/__init__.py +7 -0
- dotbrain/_cli_reference.py +115 -0
- dotbrain/adopter_repos.py +573 -0
- dotbrain/beads.py +511 -0
- dotbrain/bootstrap.py +199 -0
- dotbrain/brainspaces.py +238 -0
- dotbrain/cli.py +660 -0
- dotbrain/config.py +428 -0
- dotbrain/doctor.py +279 -0
- dotbrain/hooks.py +84 -0
- dotbrain/migrate.py +304 -0
- dotbrain/paths.py +139 -0
- dotbrain/resource_loader.py +47 -0
- dotbrain/resources/__init__.py +1 -0
- dotbrain/resources/agents/claude/implementer.md +47 -0
- dotbrain/resources/agents/claude/investigator.md +35 -0
- dotbrain/resources/agents/claude/reviewer.md +38 -0
- dotbrain/resources/agents/claude/verifier.md +35 -0
- dotbrain/resources/agents/codex/implementer.toml +26 -0
- dotbrain/resources/agents/codex/investigator.toml +21 -0
- dotbrain/resources/agents/codex/reviewer.toml +27 -0
- dotbrain/resources/agents/codex/verifier.toml +21 -0
- dotbrain/resources/config.yaml +20 -0
- dotbrain/resources/core.yaml +18 -0
- dotbrain/resources/templates/brain/AGENTS.md +9 -0
- dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
- dotbrain/resources/templates/brain/adr/README.md +8 -0
- dotbrain/resources/templates/brain/designs/README.md +28 -0
- dotbrain/resources/templates/brain/docs/README.md +9 -0
- dotbrain/resources/templates/brain/project.yaml +27 -0
- dotbrain/resources/templates/gitignore +17 -0
- dotbrain/skills.py +264 -0
- dotbrain/subagents.py +252 -0
- dotbrain/updater.py +105 -0
- dotbrain/workflows.py +529 -0
- dotbrain-0.3.4.dist-info/METADATA +21 -0
- dotbrain-0.3.4.dist-info/RECORD +40 -0
- dotbrain-0.3.4.dist-info/WHEEL +4 -0
- dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
- dotbrain-0.3.4.dist-info/licenses/LICENSE +21 -0
dotbrain/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Generator for ``docs/cli-reference.md``.
|
|
2
|
+
|
|
3
|
+
The committed page is a fixed-width (80-col) snapshot of ``dotbrain <cmd> --help`` for
|
|
4
|
+
every non-hidden command. ``test_cli_reference.py`` regenerates and diffs against the
|
|
5
|
+
committed file, failing when the CLI surface drifts. Regenerate with::
|
|
6
|
+
|
|
7
|
+
uv run python -m dotbrain._cli_reference
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from .cli import app
|
|
22
|
+
|
|
23
|
+
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
24
|
+
REFERENCE_PATH = _REPO_ROOT / "docs" / "cli-reference.md"
|
|
25
|
+
|
|
26
|
+
_INTRO = "# CLI Reference\n\nReference for the public `dotbrain` CLI.\n\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def console_script() -> Path:
|
|
30
|
+
"""Locate the installed ``dotbrain`` entry point (next to the venv python)."""
|
|
31
|
+
exe = Path(sys.executable).with_name("dotbrain")
|
|
32
|
+
if exe.exists():
|
|
33
|
+
return exe
|
|
34
|
+
found = shutil.which("dotbrain")
|
|
35
|
+
if found:
|
|
36
|
+
return Path(found)
|
|
37
|
+
raise FileNotFoundError("dotbrain console script not found; install the package first")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def command_paths() -> list[list[str]]:
|
|
41
|
+
"""Pre-order, alphabetically-sorted command paths, skipping hidden commands."""
|
|
42
|
+
root = typer.main.get_command(app)
|
|
43
|
+
|
|
44
|
+
def walk(cmd: object, path: list[str]) -> list[list[str]]:
|
|
45
|
+
out = [path]
|
|
46
|
+
subs = getattr(cmd, "commands", None)
|
|
47
|
+
if subs:
|
|
48
|
+
for name in sorted(subs):
|
|
49
|
+
sub = subs[name]
|
|
50
|
+
if getattr(sub, "hidden", False):
|
|
51
|
+
continue
|
|
52
|
+
out += walk(sub, path + [name])
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
return walk(root, [])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Rich draws Typer's help panels with rounded corners, except where Console.legacy_windows
|
|
59
|
+
# is true — a captured pipe on Windows — where box.ROUNDED substitutes the square set. The
|
|
60
|
+
# committed doc would otherwise depend on which OS regenerated it, so normalize to square.
|
|
61
|
+
_SQUARE_CORNERS = str.maketrans("╭╮╰╯", "┌┐└┘")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _help(exe: Path, path: list[str]) -> str:
|
|
65
|
+
# PYTHONUTF8 matters beyond decoding: Rich's Console.ascii_only checks whether the captured
|
|
66
|
+
# stdout's encoding starts with "utf" and falls back to ASCII box-drawing characters otherwise.
|
|
67
|
+
# A captured pipe's default encoding on Windows is the legacy ANSI codepage, not UTF-8, unless
|
|
68
|
+
# this is set — so without it, the reference doc's box borders differ only on Windows.
|
|
69
|
+
env = {**os.environ, "COLUMNS": "80", "PYTHONUTF8": "1"}
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
[str(exe), *path, "--help"],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
env=env,
|
|
75
|
+
check=True,
|
|
76
|
+
)
|
|
77
|
+
output = result.stdout.rstrip("\n")
|
|
78
|
+
# Click derives its usage-line prog_name from argv[0]'s basename: the installed console
|
|
79
|
+
# script's actual filename, which is "dotbrain" on POSIX but "dotbrain.exe"/"dotbrain.EXE" on
|
|
80
|
+
# Windows. Normalize to the canonical name so the committed doc doesn't depend on platform or
|
|
81
|
+
# installer casing. Match the exact invoked name (extension included), not a bare "dotbrain"
|
|
82
|
+
# substring — the help text itself contains unrelated words like "$DOTBRAIN_HOME".
|
|
83
|
+
if exe.name.lower() != "dotbrain":
|
|
84
|
+
output = re.sub(re.escape(exe.name), "dotbrain", output, flags=re.IGNORECASE)
|
|
85
|
+
output = output.translate(_SQUARE_CORNERS)
|
|
86
|
+
return "\n".join(line.rstrip() for line in output.splitlines())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render() -> str:
|
|
90
|
+
exe = console_script()
|
|
91
|
+
sections = []
|
|
92
|
+
for path in command_paths():
|
|
93
|
+
suffix = (" " + " ".join(path)) if path else ""
|
|
94
|
+
sections.append(f"## `dotbrain{suffix}`\n\n```text\n{_help(exe, path)}\n```")
|
|
95
|
+
return _INTRO + "\n\n".join(sections) + "\n"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> None:
|
|
99
|
+
# Rich narrows its console by one column when legacy_windows is on, which it always is
|
|
100
|
+
# for a captured subprocess pipe on Windows. The committed page is therefore the POSIX
|
|
101
|
+
# rendering, and test_cli_reference.py skips on Windows for the same reason. Regenerating
|
|
102
|
+
# here would write an 80-col page as 79 and break CI's ubuntu/macos legs, where the guard
|
|
103
|
+
# actually runs — so refuse rather than write a page this platform cannot validate.
|
|
104
|
+
if sys.platform == "win32":
|
|
105
|
+
raise SystemExit(
|
|
106
|
+
"refusing to regenerate docs/cli-reference.md on Windows: Rich renders panels one "
|
|
107
|
+
"column narrower here, and the committed page is the POSIX rendering that CI "
|
|
108
|
+
"validates. Regenerate under WSL, Linux, or macOS."
|
|
109
|
+
)
|
|
110
|
+
REFERENCE_PATH.write_text(render(), encoding="utf-8", newline="\n")
|
|
111
|
+
print(f"wrote {REFERENCE_PATH}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Adopter repo attachment, detachment, and Brainspace link reconciliation.
|
|
2
|
+
|
|
3
|
+
A Brainspace is a project's private context store; an adopter repo is an external checkout wired
|
|
4
|
+
into it. This module owns everything repo-facing:
|
|
5
|
+
|
|
6
|
+
- Brainspace link reconciliation;
|
|
7
|
+
- repo path resolution for a Brainspace (``repo_for_brainspace``);
|
|
8
|
+
- the foreign-dotbrain guards that refuse to hijack a repo already wired elsewhere;
|
|
9
|
+
- ``.git/info/exclude`` and agent-context pointer (AGENTS.md/CLAUDE.md) management;
|
|
10
|
+
- repo attachment (``wire_repo``), verification, and detachment (``unwire_repo``).
|
|
11
|
+
|
|
12
|
+
It depends only on ``paths``: every other concept module points into it, never the reverse.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from dotbrain import paths
|
|
25
|
+
|
|
26
|
+
# A subprocess seam: same shape as ``subprocess.run`` but easy to fake in tests.
|
|
27
|
+
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_run(
|
|
31
|
+
argv: Sequence[str], *, cwd: Path | None = None, env: dict | None = None, check: bool = True
|
|
32
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
33
|
+
# stdin=DEVNULL is load-bearing: bd auto-enables non-interactive mode on a non-TTY stdin,
|
|
34
|
+
# so destructive steps (e.g. bd init --reinit-local) skip their confirmation prompt instead
|
|
35
|
+
# of blocking forever on terminal input while capture_output swallows the prompt text.
|
|
36
|
+
return subprocess.run(
|
|
37
|
+
list(argv), cwd=cwd, env=env, check=check,
|
|
38
|
+
capture_output=True, encoding="utf-8", stdin=subprocess.DEVNULL,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------- Brainspace links
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ReconcileResult:
|
|
47
|
+
created: list[str] = field(default_factory=list)
|
|
48
|
+
repaired: list[str] = field(default_factory=list)
|
|
49
|
+
skipped: list[str] = field(default_factory=list)
|
|
50
|
+
collisions: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _symlink_directory(path: Path, target_str: str) -> None:
|
|
54
|
+
"""Create a directory symlink at ``path`` or raise a clear privilege failure."""
|
|
55
|
+
try:
|
|
56
|
+
path.symlink_to(target_str, target_is_directory=True)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
message = paths.symlink_privilege_message(exc)
|
|
59
|
+
if message is None:
|
|
60
|
+
raise
|
|
61
|
+
raise RuntimeError(f"{path}: {message}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def reconcile(directory: Path, targets: dict[str, Path]) -> ReconcileResult:
|
|
65
|
+
"""Reconcile a Brainspace link mapping into one directory."""
|
|
66
|
+
directory = Path(directory)
|
|
67
|
+
result = ReconcileResult()
|
|
68
|
+
|
|
69
|
+
for name, target in targets.items():
|
|
70
|
+
path = directory / name
|
|
71
|
+
target = Path(target)
|
|
72
|
+
target_str = str(target)
|
|
73
|
+
|
|
74
|
+
if not target.exists():
|
|
75
|
+
result.skipped.append(name)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if path.is_symlink():
|
|
79
|
+
if paths.symlink_target_matches(os.readlink(path), target_str):
|
|
80
|
+
continue
|
|
81
|
+
path.unlink()
|
|
82
|
+
_symlink_directory(path, target_str)
|
|
83
|
+
result.repaired.append(name)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if path.exists():
|
|
87
|
+
result.collisions.append(name)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
_symlink_directory(path, target_str)
|
|
91
|
+
result.created.append(name)
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------- repo path resolution
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def expand_path(raw: str, home: Path | None = None) -> Path:
|
|
100
|
+
"""Expand a tilde-prefixed path to absolute. Mirrors bootstrap.sh expand_path.
|
|
101
|
+
|
|
102
|
+
Uses string offset (not pattern stripping) to avoid bash's tilde-in-pattern expansion bug.
|
|
103
|
+
"""
|
|
104
|
+
h = Path(home) if home is not None else Path.home()
|
|
105
|
+
if raw == "~":
|
|
106
|
+
return h
|
|
107
|
+
if raw.startswith(("~/", "~\\")):
|
|
108
|
+
return h / raw[2:]
|
|
109
|
+
return Path(raw)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def repo_for_brainspace(
|
|
113
|
+
brainspace: Path,
|
|
114
|
+
dotbrain_home: Path,
|
|
115
|
+
repo_base: Path | None = None,
|
|
116
|
+
home: Path | None = None,
|
|
117
|
+
) -> Path | None:
|
|
118
|
+
"""Resolve the adopter repo path for a Brainspace. Mirrors bootstrap.sh repo_for_brainspace.
|
|
119
|
+
|
|
120
|
+
Resolution order:
|
|
121
|
+
1. <brainspace>/.repo.local (machine-local override)
|
|
122
|
+
2. <brainspace>/.repo (committed canonical pointer)
|
|
123
|
+
3. dotbrain_home itself when brainspace.name == "dotbrain"
|
|
124
|
+
4. repo_base/<brainspace.name> when the directory exists
|
|
125
|
+
|
|
126
|
+
A ``(brain-only)`` pointer resolves to ``None``: the Brainspace declares no adopter repo.
|
|
127
|
+
"""
|
|
128
|
+
for pointer_name in (".repo.local", ".repo"):
|
|
129
|
+
pointer = brainspace / pointer_name
|
|
130
|
+
if pointer.is_file():
|
|
131
|
+
lines = [
|
|
132
|
+
l.strip() for l in pointer.read_text(encoding="utf-8").splitlines()
|
|
133
|
+
if l.strip() and not l.strip().startswith("#")
|
|
134
|
+
]
|
|
135
|
+
if lines:
|
|
136
|
+
if lines[0] == "(brain-only)":
|
|
137
|
+
return None
|
|
138
|
+
return expand_path(lines[0], home)
|
|
139
|
+
if brainspace.name == "dotbrain":
|
|
140
|
+
return Path(dotbrain_home).resolve()
|
|
141
|
+
if repo_base:
|
|
142
|
+
candidate = Path(repo_base) / brainspace.name
|
|
143
|
+
if candidate.is_dir():
|
|
144
|
+
return candidate
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------- pure helpers
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def abbrev_home(path: Path, home: Path | None = None) -> str:
|
|
152
|
+
"""Render ``path`` with ``$HOME`` collapsed to ``~`` (mirrors the script's abbrev_home)."""
|
|
153
|
+
home = Path(home) if home is not None else Path.home()
|
|
154
|
+
path = Path(path)
|
|
155
|
+
if path == home:
|
|
156
|
+
return "~"
|
|
157
|
+
try:
|
|
158
|
+
return f"~/{path.relative_to(home).as_posix()}"
|
|
159
|
+
except ValueError:
|
|
160
|
+
return path.as_posix()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_dotbrain_repo(repo: Path, dotbrain_home: Path) -> bool:
|
|
164
|
+
return Path(repo).resolve() == Path(dotbrain_home).resolve()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def target_is_outside_repo(repo: Path, path: Path) -> bool:
|
|
168
|
+
"""True when ``path`` resolves to a location outside ``repo`` (broken target -> inside)."""
|
|
169
|
+
repo_real = Path(repo).resolve()
|
|
170
|
+
try:
|
|
171
|
+
target_real = Path(path).resolve(strict=True)
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
|
174
|
+
return repo_real != target_real and repo_real not in target_real.parents
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_dotbrain_checkout(root: Path) -> bool:
|
|
178
|
+
"""True when ``root`` has the minimum structure expected of a dotbrain checkout."""
|
|
179
|
+
root = Path(root).resolve()
|
|
180
|
+
return (
|
|
181
|
+
(root / ".git").exists()
|
|
182
|
+
and any((root / d).is_dir() for d in paths.DATA_DIRS)
|
|
183
|
+
and (root / "templates" / ".brain" / "AGENTS.md").is_file()
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def foreign_dotbrain_home_for_symlink(path: Path, link_name: str, dotbrain_home: Path) -> Path | None:
|
|
188
|
+
"""Return a foreign dotbrain root when ``path`` proves it already belongs to another checkout."""
|
|
189
|
+
path = Path(path)
|
|
190
|
+
if not path.is_symlink():
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
target = path.resolve(strict=True)
|
|
194
|
+
except OSError:
|
|
195
|
+
return None
|
|
196
|
+
if target.name != link_name:
|
|
197
|
+
return None
|
|
198
|
+
project_dir = target.parent
|
|
199
|
+
data_dir = project_dir.parent
|
|
200
|
+
if data_dir.name not in paths.DATA_DIRS:
|
|
201
|
+
return None
|
|
202
|
+
inferred_root = data_dir.parent.resolve()
|
|
203
|
+
if inferred_root == Path(dotbrain_home).resolve():
|
|
204
|
+
return None
|
|
205
|
+
if not is_dotbrain_checkout(inferred_root):
|
|
206
|
+
return None
|
|
207
|
+
if target != paths.brainspace(inferred_root, project_dir.name) / link_name:
|
|
208
|
+
return None
|
|
209
|
+
return inferred_root
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ensure_not_wired_to_foreign_dotbrain(repo: Path, dotbrain_home: Path) -> None:
|
|
213
|
+
"""Refuse rewiring when an adopter Brainspace link already resolves into another dotbrain checkout."""
|
|
214
|
+
repo = Path(repo)
|
|
215
|
+
current_root = Path(dotbrain_home).resolve()
|
|
216
|
+
for name in paths.BRAINSPACE_LINKS:
|
|
217
|
+
link = repo / name
|
|
218
|
+
foreign_root = foreign_dotbrain_home_for_symlink(link, name, current_root)
|
|
219
|
+
if foreign_root is None:
|
|
220
|
+
continue
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"{link} resolves into another dotbrain checkout at {foreign_root}; "
|
|
223
|
+
f"unwire {repo} or repoint {name} before wiring it to {current_root}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def repo_root(repo: Path | None, run: Runner = _default_run) -> Path:
|
|
228
|
+
cwd = Path(repo) if repo is not None else None
|
|
229
|
+
argv = ["git", "rev-parse", "--show-toplevel"]
|
|
230
|
+
try:
|
|
231
|
+
res = run(argv, cwd=cwd, check=True)
|
|
232
|
+
except subprocess.CalledProcessError:
|
|
233
|
+
loc = str(cwd) if cwd else "current directory"
|
|
234
|
+
raise ValueError(f"{loc} is not inside a git repository; run 'git init' first or use --no-repo --name <name>")
|
|
235
|
+
return Path((res.stdout or "").strip()).resolve()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --------------------------------------------------------------------------- excludes & symlinks
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def ensure_exclude_line(exclude_file: Path, line: str) -> None:
|
|
242
|
+
exclude_file = Path(exclude_file)
|
|
243
|
+
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
existing = (
|
|
245
|
+
exclude_file.read_text(encoding="utf-8").splitlines()
|
|
246
|
+
if exclude_file.is_file()
|
|
247
|
+
else []
|
|
248
|
+
)
|
|
249
|
+
if line in existing:
|
|
250
|
+
return
|
|
251
|
+
body = exclude_file.read_text(encoding="utf-8") if exclude_file.is_file() else ""
|
|
252
|
+
if body and not body.endswith("\n"):
|
|
253
|
+
body += "\n"
|
|
254
|
+
exclude_file.write_text(body + line + "\n", encoding="utf-8", newline="\n")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def remove_exclude_line(exclude_file: Path, line: str) -> None:
|
|
258
|
+
exclude_file = Path(exclude_file)
|
|
259
|
+
if not exclude_file.is_file():
|
|
260
|
+
return
|
|
261
|
+
lines = exclude_file.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
262
|
+
filtered = [existing for existing in lines if existing.rstrip("\r\n") != line]
|
|
263
|
+
if len(filtered) != len(lines):
|
|
264
|
+
exclude_file.write_text("".join(filtered), encoding="utf-8", newline="\n")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def git_exclude_file(repo: Path, run: Runner = _default_run) -> Path | None:
|
|
268
|
+
"""Return the shared exclude file used by a repo and all its worktrees."""
|
|
269
|
+
res = run(["git", "-C", str(repo), "rev-parse", "--git-common-dir"], check=False)
|
|
270
|
+
raw = (res.stdout or "").strip() if res.returncode == 0 else ""
|
|
271
|
+
if not raw:
|
|
272
|
+
return None
|
|
273
|
+
common_dir = Path(raw)
|
|
274
|
+
if not common_dir.is_absolute():
|
|
275
|
+
common_dir = Path(repo) / common_dir
|
|
276
|
+
return common_dir.resolve() / "info" / "exclude"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def ensure_local_exclude_line(repo: Path, line: str, run: Runner = _default_run) -> None:
|
|
280
|
+
exclude_file = git_exclude_file(repo, run)
|
|
281
|
+
if exclude_file is not None:
|
|
282
|
+
ensure_exclude_line(exclude_file, line)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def remove_local_exclude_line(repo: Path, line: str, run: Runner = _default_run) -> None:
|
|
286
|
+
exclude_file = git_exclude_file(repo, run)
|
|
287
|
+
if exclude_file is not None:
|
|
288
|
+
remove_exclude_line(exclude_file, line)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def reconcile_link_excludes(
|
|
292
|
+
repo: Path,
|
|
293
|
+
*,
|
|
294
|
+
linked: Sequence[str] = (),
|
|
295
|
+
pruned: Sequence[str] = (),
|
|
296
|
+
run: Runner = _default_run,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Add and remove exact repo-relative excludes for reconciled links."""
|
|
299
|
+
exclude_file = git_exclude_file(repo, run)
|
|
300
|
+
if exclude_file is None:
|
|
301
|
+
return
|
|
302
|
+
for entry in linked:
|
|
303
|
+
ensure_exclude_line(exclude_file, f"/{entry.strip('/').replace(os.sep, '/')}")
|
|
304
|
+
for entry in pruned:
|
|
305
|
+
remove_exclude_line(exclude_file, f"/{entry.strip('/').replace(os.sep, '/')}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def materialize_workspace(
|
|
309
|
+
repo: Path,
|
|
310
|
+
brainspace: Path,
|
|
311
|
+
name: str,
|
|
312
|
+
run: Runner = _default_run,
|
|
313
|
+
) -> str | None:
|
|
314
|
+
"""Replace a dotbrain-owned workspace link with a project-owned directory."""
|
|
315
|
+
repo = Path(repo)
|
|
316
|
+
# The mkdir below passes parents=True, so a .repo pointing at a path that does not
|
|
317
|
+
# exist would conjure the whole tree rather than fail: refreshing a Brainspace whose
|
|
318
|
+
# repo was moved or deleted silently created a phantom repo at the stale location.
|
|
319
|
+
if not repo.is_dir():
|
|
320
|
+
return f"{repo} does not exist; skipping {name} workspace"
|
|
321
|
+
workspace = repo / name
|
|
322
|
+
expected = Path(brainspace) / name
|
|
323
|
+
if workspace.is_symlink():
|
|
324
|
+
if not paths.symlink_target_matches(os.readlink(workspace), str(expected)):
|
|
325
|
+
return f"{workspace} is not a dotbrain workspace link; leaving it unchanged"
|
|
326
|
+
workspace.unlink()
|
|
327
|
+
elif workspace.exists() and not workspace.is_dir():
|
|
328
|
+
return f"{workspace} exists and is not a directory; leaving it unchanged"
|
|
329
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
remove_local_exclude_line(repo, f"/{name}", run)
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def ensure_symlink(repo: Path, name: str, target: Path) -> str | None:
|
|
335
|
+
"""Create/repair ``repo/name`` -> ``target``. Returns a warning if a real path is in the way."""
|
|
336
|
+
path = Path(repo) / name
|
|
337
|
+
target_str = str(target)
|
|
338
|
+
if path.is_symlink():
|
|
339
|
+
if paths.symlink_target_matches(os.readlink(path), target_str):
|
|
340
|
+
return None
|
|
341
|
+
path.unlink()
|
|
342
|
+
elif path.exists():
|
|
343
|
+
return f"{path} exists and is not a symlink; leaving it unchanged"
|
|
344
|
+
return _symlink_directory(path, target_str)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def warn_if_tracked_external_symlink(repo: Path, name: str, run: Runner = _default_run) -> str | None:
|
|
348
|
+
path = Path(repo) / name
|
|
349
|
+
if not path.is_symlink() or not target_is_outside_repo(repo, path):
|
|
350
|
+
return None
|
|
351
|
+
res = run(["git", "-C", str(repo), "ls-files", "--error-unmatch", name], check=False)
|
|
352
|
+
if res.returncode == 0:
|
|
353
|
+
return f"{path} points outside {repo} but is tracked; remove it with git rm --cached {name}"
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def append_pointer_to_file(file: Path, pointer: str) -> str | None:
|
|
358
|
+
file = Path(file)
|
|
359
|
+
if file.is_symlink():
|
|
360
|
+
try:
|
|
361
|
+
target = file.resolve(strict=True)
|
|
362
|
+
except OSError:
|
|
363
|
+
return f"{file} is a broken symlink; leaving it unchanged"
|
|
364
|
+
else:
|
|
365
|
+
target = file
|
|
366
|
+
if not target.is_file():
|
|
367
|
+
return f"{target} is not a regular file; leaving it unchanged"
|
|
368
|
+
text = target.read_text(encoding="utf-8")
|
|
369
|
+
if "@.brain/CLAUDE.md" in text:
|
|
370
|
+
return None
|
|
371
|
+
if text and not text.endswith("\n"):
|
|
372
|
+
text += "\n"
|
|
373
|
+
target.write_text(f"{text}\n{pointer}\n", encoding="utf-8", newline="\n")
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def ensure_agent_context_pointer(repo: Path, pointer: str = paths.ADOPTER_POINTER) -> list[str]:
|
|
378
|
+
repo = Path(repo)
|
|
379
|
+
agents = repo / "AGENTS.md"
|
|
380
|
+
claude = repo / "CLAUDE.md"
|
|
381
|
+
if not agents.exists() and not claude.exists():
|
|
382
|
+
agents.write_text(f"{pointer}\n", encoding="utf-8", newline="\n")
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
warnings: list[str] = []
|
|
386
|
+
seen: set[Path] = set()
|
|
387
|
+
for file in (agents, claude):
|
|
388
|
+
if not file.exists():
|
|
389
|
+
continue
|
|
390
|
+
if file.is_symlink():
|
|
391
|
+
try:
|
|
392
|
+
target = file.resolve(strict=True)
|
|
393
|
+
except OSError:
|
|
394
|
+
warnings.append(f"{file} is a broken symlink; leaving it unchanged")
|
|
395
|
+
continue
|
|
396
|
+
else:
|
|
397
|
+
target = file
|
|
398
|
+
if target in seen:
|
|
399
|
+
continue
|
|
400
|
+
seen.add(target)
|
|
401
|
+
warning = append_pointer_to_file(file, pointer)
|
|
402
|
+
if warning:
|
|
403
|
+
warnings.append(warning)
|
|
404
|
+
return warnings
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --------------------------------------------------------------------------- attach / verify
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def wire_repo(
|
|
411
|
+
repo: Path,
|
|
412
|
+
brainspace: Path,
|
|
413
|
+
dotbrain_home: Path,
|
|
414
|
+
run: Runner = _default_run,
|
|
415
|
+
*,
|
|
416
|
+
skip_beads_link: bool = False,
|
|
417
|
+
workspace_links: Sequence[str] = (),
|
|
418
|
+
) -> list[str]:
|
|
419
|
+
"""Link active Brainspace symlinks into ``repo`` and add local excludes."""
|
|
420
|
+
repo = Path(repo)
|
|
421
|
+
ensure_not_wired_to_foreign_dotbrain(repo, dotbrain_home)
|
|
422
|
+
use_local_excludes = not is_dotbrain_repo(repo, dotbrain_home)
|
|
423
|
+
warnings: list[str] = []
|
|
424
|
+
active_links: tuple[str, ...] = (".brain", *(workspace_links or ()))
|
|
425
|
+
if not skip_beads_link:
|
|
426
|
+
active_links += (".beads",)
|
|
427
|
+
targets = {name: Path(brainspace) / name for name in active_links}
|
|
428
|
+
result = reconcile(repo, targets)
|
|
429
|
+
for name in result.skipped:
|
|
430
|
+
warnings.append(f"{targets[name]} is missing; skipping {repo}/{name}")
|
|
431
|
+
for name in result.collisions:
|
|
432
|
+
warnings.append(f"{repo / name} exists and is not a symlink; leaving it unchanged")
|
|
433
|
+
for name in targets:
|
|
434
|
+
if use_local_excludes:
|
|
435
|
+
ensure_local_exclude_line(repo, f"/{name}", run)
|
|
436
|
+
tracked = warn_if_tracked_external_symlink(repo, name, run)
|
|
437
|
+
if tracked:
|
|
438
|
+
warnings.append(tracked)
|
|
439
|
+
return warnings
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def verify_wiring(repo: Path, run: Runner = _default_run, *, expected_links: Sequence[str] = paths.BRAINSPACE_LINKS) -> list[str]:
|
|
443
|
+
repo = Path(repo)
|
|
444
|
+
warnings: list[str] = []
|
|
445
|
+
for name in expected_links:
|
|
446
|
+
link = repo / name
|
|
447
|
+
if not link.is_symlink():
|
|
448
|
+
warnings.append(f"{repo}/{name} is not wired")
|
|
449
|
+
continue
|
|
450
|
+
try:
|
|
451
|
+
link.resolve(strict=True)
|
|
452
|
+
except OSError:
|
|
453
|
+
warnings.append(f"{repo}/{name} is a broken symlink")
|
|
454
|
+
if shutil.which("bd") and (repo / ".beads").is_dir():
|
|
455
|
+
res = run(["bd", "-C", str(repo), "ready"], check=False)
|
|
456
|
+
if res.returncode != 0:
|
|
457
|
+
warnings.append(f"bd ready failed in {repo}")
|
|
458
|
+
return warnings
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# --------------------------------------------------------------------------- detach
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@dataclass
|
|
465
|
+
class UnwireResult:
|
|
466
|
+
project: str = ""
|
|
467
|
+
repo: Path | None = None
|
|
468
|
+
logs: list[str] = field(default_factory=list)
|
|
469
|
+
warnings: list[str] = field(default_factory=list)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _managed_workspace_links(repo: Path, dotbrain_home: Path) -> list[Path]:
|
|
473
|
+
root = Path(dotbrain_home).resolve()
|
|
474
|
+
links: list[Path] = []
|
|
475
|
+
for name in (".claude", ".codex"):
|
|
476
|
+
workspace = Path(repo) / name
|
|
477
|
+
if workspace.is_symlink():
|
|
478
|
+
candidates = [workspace]
|
|
479
|
+
elif workspace.is_dir():
|
|
480
|
+
candidates = list(workspace.rglob("*"))
|
|
481
|
+
else:
|
|
482
|
+
continue
|
|
483
|
+
for entry in candidates:
|
|
484
|
+
if not entry.is_symlink():
|
|
485
|
+
continue
|
|
486
|
+
try:
|
|
487
|
+
if entry.resolve().is_relative_to(root):
|
|
488
|
+
links.append(entry)
|
|
489
|
+
except OSError:
|
|
490
|
+
continue
|
|
491
|
+
return links
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _remove_empty_workspace_dirs(repo: Path) -> list[Path]:
|
|
495
|
+
removed: list[Path] = []
|
|
496
|
+
for name in (".claude", ".codex"):
|
|
497
|
+
workspace = Path(repo) / name
|
|
498
|
+
for directory in sorted(
|
|
499
|
+
(path for path in workspace.rglob("*") if path.is_dir() and not path.is_symlink()),
|
|
500
|
+
key=lambda path: len(path.parts),
|
|
501
|
+
reverse=True,
|
|
502
|
+
) if workspace.is_dir() and not workspace.is_symlink() else []:
|
|
503
|
+
if not any(directory.iterdir()):
|
|
504
|
+
directory.rmdir()
|
|
505
|
+
if workspace.is_dir() and not workspace.is_symlink() and not any(workspace.iterdir()):
|
|
506
|
+
workspace.rmdir()
|
|
507
|
+
removed.append(workspace)
|
|
508
|
+
return removed
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def unwire_repo(
|
|
512
|
+
repo: Path,
|
|
513
|
+
dry_run: bool = False,
|
|
514
|
+
*,
|
|
515
|
+
dotbrain_home: Path | None = None,
|
|
516
|
+
run: Runner = _default_run,
|
|
517
|
+
) -> UnwireResult:
|
|
518
|
+
"""Remove agent workspace symlinks, exclude entries, and the adopter-context pointer.
|
|
519
|
+
|
|
520
|
+
With ``dry_run`` the repo is left untouched; logs report what would be removed.
|
|
521
|
+
"""
|
|
522
|
+
result = UnwireResult(repo=repo)
|
|
523
|
+
verb = "would remove" if dry_run else "removed"
|
|
524
|
+
|
|
525
|
+
managed_links = _managed_workspace_links(repo, dotbrain_home) if dotbrain_home else []
|
|
526
|
+
managed_entries = [link.relative_to(repo).as_posix() for link in managed_links]
|
|
527
|
+
|
|
528
|
+
for name in paths.BRAINSPACE_LINKS:
|
|
529
|
+
link = repo / name
|
|
530
|
+
if link.is_symlink():
|
|
531
|
+
if not dry_run:
|
|
532
|
+
link.unlink()
|
|
533
|
+
result.logs.append(f"{verb} symlink {name}")
|
|
534
|
+
|
|
535
|
+
for link, entry in zip(managed_links, managed_entries):
|
|
536
|
+
if not dry_run:
|
|
537
|
+
link.unlink()
|
|
538
|
+
result.logs.append(f"{verb} workspace link {entry}")
|
|
539
|
+
|
|
540
|
+
exclude = git_exclude_file(repo, run)
|
|
541
|
+
if exclude and exclude.is_file():
|
|
542
|
+
lines = exclude.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
543
|
+
excludes = {*paths.EXCLUDE_ENTRIES, "/.claude", "/.codex"}
|
|
544
|
+
excludes.update(f"/{entry}" for entry in managed_entries)
|
|
545
|
+
filtered = [l for l in lines if l.rstrip("\r\n") not in excludes]
|
|
546
|
+
if len(filtered) < len(lines):
|
|
547
|
+
if not dry_run:
|
|
548
|
+
exclude.write_text("".join(filtered), encoding="utf-8", newline="\n")
|
|
549
|
+
result.logs.append(f"{verb} dotbrain ignore rules from .git/info/exclude")
|
|
550
|
+
|
|
551
|
+
if not dry_run:
|
|
552
|
+
for workspace in _remove_empty_workspace_dirs(repo):
|
|
553
|
+
result.logs.append(f"removed empty workspace {workspace.name}")
|
|
554
|
+
|
|
555
|
+
for fname in ("AGENTS.md", "CLAUDE.md"):
|
|
556
|
+
f = repo / fname
|
|
557
|
+
if not f.exists() or f.is_symlink():
|
|
558
|
+
continue
|
|
559
|
+
text = f.read_text(encoding="utf-8")
|
|
560
|
+
pointer_lines = set(paths.ADOPTER_POINTER.strip().splitlines())
|
|
561
|
+
if any(pl in text for pl in pointer_lines):
|
|
562
|
+
if not dry_run:
|
|
563
|
+
cleaned = "\n".join(
|
|
564
|
+
l for l in text.splitlines() if l.strip() not in pointer_lines
|
|
565
|
+
).strip()
|
|
566
|
+
f.write_text(
|
|
567
|
+
cleaned + "\n" if cleaned else "",
|
|
568
|
+
encoding="utf-8",
|
|
569
|
+
newline="\n",
|
|
570
|
+
)
|
|
571
|
+
result.logs.append(f"{verb} agent-context pointer from {fname}")
|
|
572
|
+
|
|
573
|
+
return result
|