ramets 0.3.0__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.
- ramets/__init__.py +13 -0
- ramets/__main__.py +5 -0
- ramets/cli.py +501 -0
- ramets/core/__init__.py +1 -0
- ramets/core/base.py +45 -0
- ramets/core/branch.py +116 -0
- ramets/core/db.py +335 -0
- ramets/core/delta.py +155 -0
- ramets/core/materialize.py +296 -0
- ramets/decision/__init__.py +1 -0
- ramets/decision/schema.py +85 -0
- ramets/decision/tracker.py +170 -0
- ramets/extract/__init__.py +1 -0
- ramets/extract/ast_bridge.py +80 -0
- ramets/hooks/__init__.py +1 -0
- ramets/hooks/claude.py +227 -0
- ramets/hooks/git.py +266 -0
- ramets/mcp/__init__.py +1 -0
- ramets/mcp/server.py +259 -0
- ramets/obsidian/__init__.py +1 -0
- ramets/obsidian/config.py +6 -0
- ramets/obsidian/layers.py +9 -0
- ramets/obsidian/vault.py +543 -0
- ramets-0.3.0.dist-info/METADATA +216 -0
- ramets-0.3.0.dist-info/RECORD +29 -0
- ramets-0.3.0.dist-info/WHEEL +5 -0
- ramets-0.3.0.dist-info/entry_points.txt +2 -0
- ramets-0.3.0.dist-info/licenses/LICENSE +21 -0
- ramets-0.3.0.dist-info/top_level.txt +1 -0
ramets/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ramets — Per-branch AI coding context.
|
|
2
|
+
|
|
3
|
+
Your codebase is one organism. Each branch is a ramet.
|
|
4
|
+
|
|
5
|
+
Named after Pando (Populus tremuloides), the world's largest organism —
|
|
6
|
+
a single aspen clone where individual trunks (ramets) share one root system (genet).
|
|
7
|
+
|
|
8
|
+
- Genet: the shared base knowledge graph (common ancestor of all branches)
|
|
9
|
+
- Ramets: per-branch delta-encoded graphs (individual trunks from the shared root)
|
|
10
|
+
- Decisions: structured tracking of AI suggestions and user choices
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.3.0"
|
ramets/__main__.py
ADDED
ramets/cli.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""CLI entry point — `ramets` command.
|
|
2
|
+
|
|
3
|
+
Your codebase is one organism. Each branch is a ramet.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from ramets.core.branch import (
|
|
14
|
+
current_branch,
|
|
15
|
+
get_db_path,
|
|
16
|
+
get_main_repo_root,
|
|
17
|
+
get_ramets_dir,
|
|
18
|
+
)
|
|
19
|
+
from ramets.core.db import GenetDB
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_db() -> GenetDB:
|
|
23
|
+
"""Get a database connection, or exit with helpful message."""
|
|
24
|
+
db_path = get_db_path()
|
|
25
|
+
if not db_path.exists():
|
|
26
|
+
click.echo("No ramets database found. Run `ramets init` first.", err=True)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
return GenetDB(db_path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group()
|
|
32
|
+
@click.version_option()
|
|
33
|
+
def main():
|
|
34
|
+
"""ramets — Per-branch AI coding context.
|
|
35
|
+
|
|
36
|
+
Your codebase is one organism. Each branch is a ramet.
|
|
37
|
+
|
|
38
|
+
Named after Pando (Populus tremuloides), the world's largest organism —
|
|
39
|
+
individual trunks (ramets) sharing one root system (genet).
|
|
40
|
+
"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@main.command()
|
|
45
|
+
@click.option("--no-hooks", is_flag=True, help="Skip git/Claude hook installation")
|
|
46
|
+
@click.option("--no-claude-hooks", is_flag=True, help="Skip Claude Code hook installation")
|
|
47
|
+
def init(no_hooks: bool, no_claude_hooks: bool):
|
|
48
|
+
"""Initialize ramets in the current repository.
|
|
49
|
+
|
|
50
|
+
Creates .ramets/, builds the genet (base graph), and installs hooks.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
repo_root = get_main_repo_root()
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
click.echo("Error: Not inside a git repository.", err=True)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
ramets_dir = get_ramets_dir()
|
|
59
|
+
ramets_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
# Initialize database
|
|
62
|
+
db_path = ramets_dir / "store.db"
|
|
63
|
+
db = GenetDB(db_path)
|
|
64
|
+
db.init_schema()
|
|
65
|
+
|
|
66
|
+
click.echo(f"Initialized .ramets/ at {ramets_dir}")
|
|
67
|
+
|
|
68
|
+
# Build genet
|
|
69
|
+
click.echo("Building genet (base graph)...")
|
|
70
|
+
from ramets.core.base import build_genet
|
|
71
|
+
|
|
72
|
+
graph_data = build_genet(db, repo_root)
|
|
73
|
+
node_count = len(graph_data.get("nodes", []))
|
|
74
|
+
edge_count = len(graph_data.get("links", []))
|
|
75
|
+
click.echo(f"Genet built: {node_count} nodes, {edge_count} edges")
|
|
76
|
+
|
|
77
|
+
# Register current branch
|
|
78
|
+
branch = current_branch(cwd=repo_root)
|
|
79
|
+
db.register_branch(branch)
|
|
80
|
+
click.echo(f"Registered branch: {branch}")
|
|
81
|
+
|
|
82
|
+
# Install git hooks
|
|
83
|
+
if not no_hooks:
|
|
84
|
+
from ramets.hooks.git import install_git_hooks
|
|
85
|
+
|
|
86
|
+
installed = install_git_hooks(repo_root)
|
|
87
|
+
click.echo(f"Installed git hooks: {', '.join(installed)}")
|
|
88
|
+
|
|
89
|
+
# Install Claude Code hooks
|
|
90
|
+
if not no_hooks and not no_claude_hooks:
|
|
91
|
+
from ramets.hooks.claude import install_claude_hooks
|
|
92
|
+
|
|
93
|
+
install_claude_hooks(repo_root)
|
|
94
|
+
click.echo("Installed Claude Code hooks in .claude/settings.json")
|
|
95
|
+
|
|
96
|
+
# Add .ramets/ to .gitignore
|
|
97
|
+
gitignore = repo_root / ".gitignore"
|
|
98
|
+
if gitignore.exists():
|
|
99
|
+
content = gitignore.read_text()
|
|
100
|
+
if ".ramets/" not in content:
|
|
101
|
+
with open(gitignore, "a") as f:
|
|
102
|
+
f.write("\n# ramets knowledge graph (local)\n.ramets/\n")
|
|
103
|
+
click.echo("Added .ramets/ to .gitignore")
|
|
104
|
+
else:
|
|
105
|
+
gitignore.write_text("# ramets knowledge graph (local)\n.ramets/\n")
|
|
106
|
+
click.echo("Created .gitignore with .ramets/")
|
|
107
|
+
|
|
108
|
+
db.close()
|
|
109
|
+
|
|
110
|
+
click.echo('\nReady! Try: ramets query "how does the main module work?"')
|
|
111
|
+
click.echo(f"Storage: {db_path.stat().st_size / 1024:.0f} KB")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@main.command()
|
|
115
|
+
def build():
|
|
116
|
+
"""Full genet rebuild from current HEAD."""
|
|
117
|
+
db = _get_db()
|
|
118
|
+
repo_root = get_main_repo_root()
|
|
119
|
+
|
|
120
|
+
click.echo("Rebuilding genet...")
|
|
121
|
+
from ramets.core.base import build_genet
|
|
122
|
+
|
|
123
|
+
graph_data = build_genet(db, repo_root)
|
|
124
|
+
node_count = len(graph_data.get("nodes", []))
|
|
125
|
+
edge_count = len(graph_data.get("links", []))
|
|
126
|
+
click.echo(f"Genet rebuilt: {node_count} nodes, {edge_count} edges")
|
|
127
|
+
|
|
128
|
+
db.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@main.command()
|
|
132
|
+
def update():
|
|
133
|
+
"""Incremental delta update for current branch.
|
|
134
|
+
|
|
135
|
+
Called by post-commit hook. Computes what changed vs genet.
|
|
136
|
+
"""
|
|
137
|
+
db = _get_db()
|
|
138
|
+
repo_root = get_main_repo_root()
|
|
139
|
+
branch = current_branch(cwd=repo_root)
|
|
140
|
+
|
|
141
|
+
from ramets.core.base import load_genet_or_build
|
|
142
|
+
from ramets.core.delta import compute_deltas
|
|
143
|
+
from ramets.extract.ast_bridge import extract_graph
|
|
144
|
+
|
|
145
|
+
genet = load_genet_or_build(db, repo_root)
|
|
146
|
+
branch_graph = extract_graph(repo_root)
|
|
147
|
+
deltas = compute_deltas(genet, branch_graph)
|
|
148
|
+
|
|
149
|
+
from ramets.core.branch import current_commit
|
|
150
|
+
|
|
151
|
+
commit = current_commit(cwd=repo_root)
|
|
152
|
+
|
|
153
|
+
# Replace deltas (compute_deltas returns full diff vs genet, not incremental)
|
|
154
|
+
db.conn.execute("DELETE FROM ramet_deltas WHERE branch = ?", (branch,))
|
|
155
|
+
db.conn.execute("DELETE FROM ramets WHERE branch = ?", (branch,))
|
|
156
|
+
db.conn.commit()
|
|
157
|
+
|
|
158
|
+
if deltas:
|
|
159
|
+
db.append_deltas(branch, commit, deltas)
|
|
160
|
+
click.echo(f"{len(deltas)} deltas for {branch}")
|
|
161
|
+
else:
|
|
162
|
+
click.echo(f"No changes for {branch}")
|
|
163
|
+
|
|
164
|
+
db.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@main.command()
|
|
168
|
+
def status():
|
|
169
|
+
"""Show genet freshness, ramet count, delta sizes, storage."""
|
|
170
|
+
db = _get_db()
|
|
171
|
+
|
|
172
|
+
genet_meta = db.genet_meta()
|
|
173
|
+
if genet_meta:
|
|
174
|
+
click.echo(
|
|
175
|
+
f"Genet: {genet_meta['node_count']} nodes, "
|
|
176
|
+
f"{genet_meta['edge_count']} edges "
|
|
177
|
+
f"(commit {genet_meta['commit_sha']}, built {genet_meta['built_at']})"
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
click.echo("Genet: not built")
|
|
181
|
+
|
|
182
|
+
branches = db.list_branches()
|
|
183
|
+
click.echo(f"\nBranches: {len(branches)}")
|
|
184
|
+
for b in branches:
|
|
185
|
+
delta_count = db.delta_count(b["name"])
|
|
186
|
+
ramet = db.ramet_meta(b["name"])
|
|
187
|
+
cached = f" [cached: {ramet['node_count']}n/{ramet['edge_count']}e]" if ramet else ""
|
|
188
|
+
click.echo(f" {b['name']}: {delta_count} deltas{cached}")
|
|
189
|
+
|
|
190
|
+
ramets_dir = get_ramets_dir()
|
|
191
|
+
db_size = (ramets_dir / "store.db").stat().st_size
|
|
192
|
+
click.echo(f"\nStorage: {db_size / 1024:.0f} KB ({ramets_dir})")
|
|
193
|
+
|
|
194
|
+
db.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@main.command("query")
|
|
198
|
+
@click.argument("question")
|
|
199
|
+
@click.option("--branch", "-b", default=None, help="Branch to query (default: current)")
|
|
200
|
+
@click.option("--depth", "-d", default=3, help="BFS traversal depth")
|
|
201
|
+
@click.option("--budget", default=2000, help="Approximate token budget")
|
|
202
|
+
def query_cmd(question: str, branch: str | None, depth: int, budget: int):
|
|
203
|
+
"""Query the knowledge graph."""
|
|
204
|
+
db = _get_db()
|
|
205
|
+
|
|
206
|
+
from ramets.core.materialize import query_graph
|
|
207
|
+
|
|
208
|
+
result = query_graph(db, question, branch=branch, depth=depth, token_budget=budget)
|
|
209
|
+
click.echo(result)
|
|
210
|
+
db.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@main.command("diff")
|
|
214
|
+
@click.argument("branch_a")
|
|
215
|
+
@click.argument("branch_b", default="main")
|
|
216
|
+
def diff_cmd(branch_a: str, branch_b: str):
|
|
217
|
+
"""Show graph differences between two branches."""
|
|
218
|
+
db = _get_db()
|
|
219
|
+
|
|
220
|
+
from ramets.core.materialize import diff_branches
|
|
221
|
+
|
|
222
|
+
result = diff_branches(db, branch_a, branch_b)
|
|
223
|
+
click.echo(result)
|
|
224
|
+
db.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@main.command()
|
|
228
|
+
def summary():
|
|
229
|
+
"""Print the session summary (~400 tokens)."""
|
|
230
|
+
db = _get_db()
|
|
231
|
+
|
|
232
|
+
from ramets.core.materialize import summary as gen_summary
|
|
233
|
+
|
|
234
|
+
result = gen_summary(db)
|
|
235
|
+
click.echo(result)
|
|
236
|
+
db.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@main.command()
|
|
240
|
+
@click.option("--open", "open_obsidian", is_flag=True, help="Open vault in Obsidian")
|
|
241
|
+
@click.option("--dir", "vault_dir", default=None, help="Custom output directory")
|
|
242
|
+
@click.option("--all-branches", is_flag=True, help="Generate all branch layers")
|
|
243
|
+
def obsidian(open_obsidian: bool, vault_dir: str | None, all_branches: bool):
|
|
244
|
+
"""Generate Obsidian vault with branch layers."""
|
|
245
|
+
db = _get_db()
|
|
246
|
+
|
|
247
|
+
from ramets.obsidian.vault import generate_vault
|
|
248
|
+
|
|
249
|
+
output = Path(vault_dir) if vault_dir else get_ramets_dir() / "obsidian"
|
|
250
|
+
|
|
251
|
+
branches_to_render = None
|
|
252
|
+
if all_branches:
|
|
253
|
+
branches_to_render = [b["name"] for b in db.list_branches()]
|
|
254
|
+
|
|
255
|
+
generate_vault(db, output, branches=branches_to_render)
|
|
256
|
+
click.echo(f"Obsidian vault generated at {output}")
|
|
257
|
+
|
|
258
|
+
if open_obsidian:
|
|
259
|
+
import subprocess
|
|
260
|
+
|
|
261
|
+
subprocess.run(
|
|
262
|
+
["open", f"obsidian://open?path={output}"],
|
|
263
|
+
check=False,
|
|
264
|
+
)
|
|
265
|
+
click.echo("Opening in Obsidian...")
|
|
266
|
+
|
|
267
|
+
db.close()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@main.command()
|
|
271
|
+
@click.option("--question", "-q", required=True, help="The decision question")
|
|
272
|
+
@click.option(
|
|
273
|
+
"--options",
|
|
274
|
+
"-o",
|
|
275
|
+
multiple=True,
|
|
276
|
+
required=True,
|
|
277
|
+
help="Options (repeat for each). Format: 'label' or 'label:description'",
|
|
278
|
+
)
|
|
279
|
+
@click.option("--chosen", "-c", type=int, required=True, help="Index of chosen option (0-based)")
|
|
280
|
+
@click.option(
|
|
281
|
+
"--category",
|
|
282
|
+
type=click.Choice(
|
|
283
|
+
[
|
|
284
|
+
"architecture",
|
|
285
|
+
"implementation",
|
|
286
|
+
"dependency",
|
|
287
|
+
"naming",
|
|
288
|
+
"algorithm",
|
|
289
|
+
"refactor",
|
|
290
|
+
"config",
|
|
291
|
+
"test_strategy",
|
|
292
|
+
"api_design",
|
|
293
|
+
"data_model",
|
|
294
|
+
"performance",
|
|
295
|
+
"security",
|
|
296
|
+
]
|
|
297
|
+
),
|
|
298
|
+
default="implementation",
|
|
299
|
+
help="Decision category",
|
|
300
|
+
)
|
|
301
|
+
@click.option("--rationale", "-r", default="", help="Why this option was chosen")
|
|
302
|
+
@click.option("--confidence", type=float, default=0.8, help="Confidence level (0.0-1.0)")
|
|
303
|
+
@click.option("--file", "context_files", multiple=True, help="Related files")
|
|
304
|
+
def decide(
|
|
305
|
+
question: str,
|
|
306
|
+
options: tuple[str, ...],
|
|
307
|
+
chosen: int,
|
|
308
|
+
category: str,
|
|
309
|
+
rationale: str,
|
|
310
|
+
confidence: float,
|
|
311
|
+
context_files: tuple[str, ...],
|
|
312
|
+
):
|
|
313
|
+
"""Record a design/implementation decision.
|
|
314
|
+
|
|
315
|
+
Examples:
|
|
316
|
+
ramets decide -q "Auth strategy?" -o "JWT" -o "Session cookies" -c 0 -r "Stateless"
|
|
317
|
+
ramets decide -q "ORM choice?" -o "SQLAlchemy:Full ORM" -o "Raw SQL" -c 1 --category dependency
|
|
318
|
+
"""
|
|
319
|
+
db = _get_db()
|
|
320
|
+
|
|
321
|
+
from ramets.core.branch import current_commit
|
|
322
|
+
from ramets.decision.schema import Decision, Option
|
|
323
|
+
from ramets.decision.tracker import DecisionTracker
|
|
324
|
+
|
|
325
|
+
if chosen < 0 or chosen >= len(options):
|
|
326
|
+
click.echo(f"Error: --chosen must be 0-{len(options) - 1}", err=True)
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
|
|
329
|
+
parsed_options = []
|
|
330
|
+
for opt_str in options:
|
|
331
|
+
if ":" in opt_str:
|
|
332
|
+
label, desc = opt_str.split(":", 1)
|
|
333
|
+
parsed_options.append(Option(label=label.strip(), description=desc.strip()))
|
|
334
|
+
else:
|
|
335
|
+
parsed_options.append(Option(label=opt_str.strip()))
|
|
336
|
+
|
|
337
|
+
branch = current_branch()
|
|
338
|
+
commit = current_commit()
|
|
339
|
+
|
|
340
|
+
decision = Decision(
|
|
341
|
+
branch=branch,
|
|
342
|
+
category=category,
|
|
343
|
+
question=question,
|
|
344
|
+
options=parsed_options,
|
|
345
|
+
chosen=chosen,
|
|
346
|
+
rationale=rationale,
|
|
347
|
+
confidence=confidence,
|
|
348
|
+
context_files=list(context_files),
|
|
349
|
+
commit_sha=commit,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
tracker = DecisionTracker(db)
|
|
353
|
+
did = tracker.record(decision)
|
|
354
|
+
chosen_label = parsed_options[chosen].label
|
|
355
|
+
|
|
356
|
+
click.echo(f"Decision recorded: {did}")
|
|
357
|
+
click.echo(f" Q: {question}")
|
|
358
|
+
click.echo(f" Chose: {chosen_label}")
|
|
359
|
+
if rationale:
|
|
360
|
+
click.echo(f" Why: {rationale}")
|
|
361
|
+
|
|
362
|
+
db.close()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@main.command()
|
|
366
|
+
@click.option("--branch", "-b", default=None, help="Filter by branch")
|
|
367
|
+
@click.option("--category", default=None, help="Filter by category")
|
|
368
|
+
@click.option("--status", default="active", help="Filter by status")
|
|
369
|
+
@click.option("--search", "-s", default=None, help="Full-text search")
|
|
370
|
+
@click.option("--id", "decision_id", default=None, help="Show specific decision by ID")
|
|
371
|
+
def decisions(
|
|
372
|
+
branch: str | None,
|
|
373
|
+
category: str | None,
|
|
374
|
+
status: str,
|
|
375
|
+
search: str | None,
|
|
376
|
+
decision_id: str | None,
|
|
377
|
+
):
|
|
378
|
+
"""List or search decisions for the current branch.
|
|
379
|
+
|
|
380
|
+
Examples:
|
|
381
|
+
ramets decisions # All active decisions
|
|
382
|
+
ramets decisions -s "auth" # Search for auth-related
|
|
383
|
+
ramets decisions --id abc123 # Show specific decision
|
|
384
|
+
ramets decisions --category architecture
|
|
385
|
+
"""
|
|
386
|
+
db = _get_db()
|
|
387
|
+
|
|
388
|
+
from ramets.decision.tracker import DecisionTracker
|
|
389
|
+
|
|
390
|
+
tracker = DecisionTracker(db)
|
|
391
|
+
|
|
392
|
+
if decision_id:
|
|
393
|
+
d = tracker.get(decision_id)
|
|
394
|
+
if d is None:
|
|
395
|
+
click.echo(f"Decision not found: {decision_id}", err=True)
|
|
396
|
+
sys.exit(1)
|
|
397
|
+
_print_decision_detail(d)
|
|
398
|
+
db.close()
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
if search:
|
|
402
|
+
results = tracker.search(search)
|
|
403
|
+
else:
|
|
404
|
+
if branch is None:
|
|
405
|
+
branch = current_branch()
|
|
406
|
+
results = tracker.query(branch=branch, category=category, status=status)
|
|
407
|
+
|
|
408
|
+
if not results:
|
|
409
|
+
click.echo("No decisions found.")
|
|
410
|
+
else:
|
|
411
|
+
click.echo(f"Decisions: {len(results)}")
|
|
412
|
+
for d in results:
|
|
413
|
+
chosen_label = d.options[d.chosen].label if d.options else "?"
|
|
414
|
+
status_icon = {"active": "●", "superseded": "○", "reversed": "✗", "deferred": "◌"}.get(
|
|
415
|
+
d.status.value, "?"
|
|
416
|
+
)
|
|
417
|
+
click.echo(f" {status_icon} [{d.id}] {d.question}")
|
|
418
|
+
click.echo(f" → {chosen_label} ({d.category.value}, {d.confidence or '?'})")
|
|
419
|
+
|
|
420
|
+
db.close()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _print_decision_detail(d) -> None:
|
|
424
|
+
"""Print full decision details."""
|
|
425
|
+
click.echo(f"Decision: {d.id}")
|
|
426
|
+
click.echo(f" Question: {d.question}")
|
|
427
|
+
click.echo(f" Category: {d.category.value}")
|
|
428
|
+
click.echo(f" Branch: {d.branch}")
|
|
429
|
+
click.echo(f" Status: {d.status.value}")
|
|
430
|
+
click.echo(f" Timestamp: {d.timestamp}")
|
|
431
|
+
click.echo(f" Confidence: {d.confidence}")
|
|
432
|
+
if d.commit_sha:
|
|
433
|
+
click.echo(f" Commit: {d.commit_sha}")
|
|
434
|
+
click.echo(" Options:")
|
|
435
|
+
for i, opt in enumerate(d.options):
|
|
436
|
+
marker = " ✓" if i == d.chosen else " "
|
|
437
|
+
desc = f" — {opt.description}" if opt.description else ""
|
|
438
|
+
click.echo(f" {marker} [{i}] {opt.label}{desc}")
|
|
439
|
+
if opt.pros:
|
|
440
|
+
for pro in opt.pros:
|
|
441
|
+
click.echo(f" + {pro}")
|
|
442
|
+
if opt.cons:
|
|
443
|
+
for con in opt.cons:
|
|
444
|
+
click.echo(f" - {con}")
|
|
445
|
+
if d.rationale:
|
|
446
|
+
click.echo(f" Rationale: {d.rationale}")
|
|
447
|
+
if d.context_files:
|
|
448
|
+
click.echo(f" Files: {', '.join(d.context_files)}")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@main.command()
|
|
452
|
+
def gc():
|
|
453
|
+
"""Prune dead ramets, compact the genet database."""
|
|
454
|
+
db = _get_db()
|
|
455
|
+
|
|
456
|
+
from ramets.core.branch import list_worktrees
|
|
457
|
+
|
|
458
|
+
# Get active branches from worktrees + current
|
|
459
|
+
active = {current_branch()}
|
|
460
|
+
for wt in list_worktrees():
|
|
461
|
+
if "branch" in wt:
|
|
462
|
+
active.add(wt["branch"])
|
|
463
|
+
|
|
464
|
+
# Find stale branches
|
|
465
|
+
all_branches = db.list_branches()
|
|
466
|
+
stale = [b for b in all_branches if b["name"] not in active]
|
|
467
|
+
|
|
468
|
+
if stale:
|
|
469
|
+
for b in stale:
|
|
470
|
+
db.delete_ramet(b["name"])
|
|
471
|
+
db.conn.execute("DELETE FROM branches WHERE name = ?", (b["name"],))
|
|
472
|
+
click.echo(f" Pruned: {b['name']}")
|
|
473
|
+
db.conn.commit()
|
|
474
|
+
click.echo(f"Pruned {len(stale)} stale ramets")
|
|
475
|
+
else:
|
|
476
|
+
click.echo("No stale ramets to prune")
|
|
477
|
+
|
|
478
|
+
# VACUUM to reclaim space
|
|
479
|
+
db.conn.execute("VACUUM")
|
|
480
|
+
click.echo("Database compacted")
|
|
481
|
+
|
|
482
|
+
db.close()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@main.command()
|
|
486
|
+
def serve():
|
|
487
|
+
"""Start MCP server (stdio for Claude Code)."""
|
|
488
|
+
try:
|
|
489
|
+
from ramets.mcp.server import run_server
|
|
490
|
+
|
|
491
|
+
run_server()
|
|
492
|
+
except ImportError:
|
|
493
|
+
click.echo(
|
|
494
|
+
"MCP dependencies not installed. Run: pip install ramets[mcp]",
|
|
495
|
+
err=True,
|
|
496
|
+
)
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
if __name__ == "__main__":
|
|
501
|
+
main()
|
ramets/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core storage and graph operations."""
|
ramets/core/base.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Genet builder — constructs the base (shared root) graph.
|
|
2
|
+
|
|
3
|
+
Wraps graphify's AST extraction to build the initial knowledge graph,
|
|
4
|
+
then stores it as a compressed blob in the genet table.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ramets.core.branch import current_commit, get_repo_root
|
|
12
|
+
from ramets.core.db import GenetDB
|
|
13
|
+
from ramets.extract.ast_bridge import extract_graph
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_genet(db: GenetDB, repo_path: Path | None = None) -> dict:
|
|
17
|
+
"""Build the base graph from the current HEAD and store it.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
db: The genet database connection.
|
|
21
|
+
repo_path: Repository root path. Defaults to current repo root.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The graph data dict {nodes: [...], links: [...]}.
|
|
25
|
+
"""
|
|
26
|
+
if repo_path is None:
|
|
27
|
+
repo_path = get_repo_root()
|
|
28
|
+
|
|
29
|
+
commit_sha = current_commit(cwd=repo_path)
|
|
30
|
+
|
|
31
|
+
# Extract AST graph via graphify
|
|
32
|
+
graph_data = extract_graph(repo_path)
|
|
33
|
+
|
|
34
|
+
# Store as genet
|
|
35
|
+
db.store_genet(graph_data, commit_sha)
|
|
36
|
+
|
|
37
|
+
return graph_data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_genet_or_build(db: GenetDB, repo_path: Path | None = None) -> dict:
|
|
41
|
+
"""Load genet from DB, or build if it doesn't exist."""
|
|
42
|
+
genet = db.load_genet()
|
|
43
|
+
if genet is not None:
|
|
44
|
+
return genet
|
|
45
|
+
return build_genet(db, repo_path)
|
ramets/core/branch.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Branch and worktree detection.
|
|
2
|
+
|
|
3
|
+
Resolves the current branch, the main repo root (even from worktrees),
|
|
4
|
+
and the .ramets/ data directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def git_run(*args: str, cwd: Path | None = None) -> str:
|
|
14
|
+
"""Run a git command and return stdout, stripped."""
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
["git", *args],
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
cwd=str(cwd) if cwd else None,
|
|
20
|
+
)
|
|
21
|
+
return result.stdout.strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_repo_root(cwd: Path | None = None) -> Path:
|
|
25
|
+
"""Get the working tree root (could be a worktree)."""
|
|
26
|
+
root = git_run("rev-parse", "--show-toplevel", cwd=cwd)
|
|
27
|
+
if not root:
|
|
28
|
+
raise RuntimeError("Not inside a git repository")
|
|
29
|
+
return Path(root)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_main_repo_root(cwd: Path | None = None) -> Path:
|
|
33
|
+
"""Get the main repo root, resolving through worktrees.
|
|
34
|
+
|
|
35
|
+
In a worktree, .git is a file pointing to the main repo's .git/worktrees/<name>.
|
|
36
|
+
We resolve back to the main repo root.
|
|
37
|
+
"""
|
|
38
|
+
root = get_repo_root(cwd)
|
|
39
|
+
git_common = git_run("rev-parse", "--git-common-dir", cwd=root)
|
|
40
|
+
if git_common and not git_common.startswith("--"):
|
|
41
|
+
common_path = Path(git_common)
|
|
42
|
+
if not common_path.is_absolute():
|
|
43
|
+
common_path = (root / common_path).resolve()
|
|
44
|
+
# .git/worktrees/xxx → .git → repo root
|
|
45
|
+
if common_path.name == ".git":
|
|
46
|
+
return common_path.parent
|
|
47
|
+
# Already at .git
|
|
48
|
+
return common_path.parent
|
|
49
|
+
return root
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_ramets_dir(cwd: Path | None = None) -> Path:
|
|
53
|
+
"""Get the .ramets/ directory path (at main repo root)."""
|
|
54
|
+
return get_main_repo_root(cwd) / ".ramets"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_db_path(cwd: Path | None = None) -> Path:
|
|
58
|
+
"""Get the path to store.db."""
|
|
59
|
+
return get_ramets_dir(cwd) / "store.db"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def current_branch(cwd: Path | None = None) -> str:
|
|
63
|
+
"""Get the current branch name."""
|
|
64
|
+
branch = git_run("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd)
|
|
65
|
+
return branch or "HEAD"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def current_commit(cwd: Path | None = None) -> str:
|
|
69
|
+
"""Get the current commit SHA (short)."""
|
|
70
|
+
return git_run("rev-parse", "--short", "HEAD", cwd=cwd)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def merge_base(
|
|
74
|
+
branch_a: str = "HEAD", branch_b: str = "main", cwd: Path | None = None
|
|
75
|
+
) -> str | None:
|
|
76
|
+
"""Find the merge-base commit between two branches."""
|
|
77
|
+
result = git_run("merge-base", branch_a, branch_b, cwd=cwd)
|
|
78
|
+
return result or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def changed_files(since: str = "HEAD~1", until: str = "HEAD", cwd: Path | None = None) -> list[str]:
|
|
82
|
+
"""Get files changed between two commits."""
|
|
83
|
+
output = git_run("diff", "--name-only", since, until, cwd=cwd)
|
|
84
|
+
if not output:
|
|
85
|
+
return []
|
|
86
|
+
return [f.strip() for f in output.splitlines() if f.strip()]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_worktree(cwd: Path | None = None) -> bool:
|
|
90
|
+
"""Check if the current directory is a git worktree (not the main repo)."""
|
|
91
|
+
root = get_repo_root(cwd)
|
|
92
|
+
main_root = get_main_repo_root(cwd)
|
|
93
|
+
return root != main_root
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def list_worktrees(cwd: Path | None = None) -> list[dict]:
|
|
97
|
+
"""List all worktrees for this repo."""
|
|
98
|
+
output = git_run("worktree", "list", "--porcelain", cwd=cwd)
|
|
99
|
+
if not output:
|
|
100
|
+
return []
|
|
101
|
+
worktrees = []
|
|
102
|
+
current: dict = {}
|
|
103
|
+
for line in output.splitlines():
|
|
104
|
+
if line.startswith("worktree "):
|
|
105
|
+
if current:
|
|
106
|
+
worktrees.append(current)
|
|
107
|
+
current = {"path": line.split(" ", 1)[1]}
|
|
108
|
+
elif line.startswith("HEAD "):
|
|
109
|
+
current["head"] = line.split(" ", 1)[1]
|
|
110
|
+
elif line.startswith("branch "):
|
|
111
|
+
current["branch"] = line.split(" ", 1)[1].replace("refs/heads/", "")
|
|
112
|
+
elif line == "bare":
|
|
113
|
+
current["bare"] = True
|
|
114
|
+
if current:
|
|
115
|
+
worktrees.append(current)
|
|
116
|
+
return worktrees
|