ragtime-cli 0.1.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.

Potentially problematic release.


This version of ragtime-cli might be problematic. Click here for more details.

src/cli.py ADDED
@@ -0,0 +1,773 @@
1
+ """
2
+ Ragtime CLI - semantic search and memory storage.
3
+ """
4
+
5
+ from pathlib import Path
6
+ import subprocess
7
+ import click
8
+
9
+ from .db import RagtimeDB
10
+ from .config import RagtimeConfig, init_config
11
+ from .indexers.docs import index_directory as index_docs
12
+ from .memory import Memory, MemoryStore
13
+
14
+
15
+ def get_db(project_path: Path) -> RagtimeDB:
16
+ """Get or create database for a project."""
17
+ db_path = project_path / ".ragtime" / "index"
18
+ return RagtimeDB(db_path)
19
+
20
+
21
+ def get_memory_store(project_path: Path) -> MemoryStore:
22
+ """Get memory store for a project."""
23
+ db = get_db(project_path)
24
+ return MemoryStore(project_path, db)
25
+
26
+
27
+ def get_author() -> str:
28
+ """Get the current developer's username."""
29
+ try:
30
+ # Try gh CLI first
31
+ result = subprocess.run(
32
+ ["gh", "api", "user", "--jq", ".login"],
33
+ capture_output=True,
34
+ text=True,
35
+ timeout=5,
36
+ )
37
+ if result.returncode == 0 and result.stdout.strip():
38
+ return result.stdout.strip()
39
+ except (subprocess.TimeoutExpired, FileNotFoundError):
40
+ pass
41
+
42
+ try:
43
+ # Fall back to git config
44
+ result = subprocess.run(
45
+ ["git", "config", "user.name"],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=5,
49
+ )
50
+ if result.returncode == 0 and result.stdout.strip():
51
+ return result.stdout.strip().lower().replace(" ", "-")
52
+ except (subprocess.TimeoutExpired, FileNotFoundError):
53
+ pass
54
+
55
+ return "unknown"
56
+
57
+
58
+ @click.group()
59
+ @click.version_option(version="0.1.0")
60
+ def main():
61
+ """Ragtime - semantic search over code and documentation."""
62
+ pass
63
+
64
+
65
+ @main.command()
66
+ @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
67
+ def init(path: Path):
68
+ """Initialize ragtime config for a project."""
69
+ path = path.resolve()
70
+ config = init_config(path)
71
+ click.echo(f"Created .ragtime/config.yaml with defaults:")
72
+ click.echo(f" Docs paths: {config.docs.paths}")
73
+ click.echo(f" Code paths: {config.code.paths}")
74
+ click.echo(f" Languages: {config.code.languages}")
75
+
76
+
77
+ @main.command()
78
+ @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
79
+ @click.option("--type", "index_type", type=click.Choice(["all", "docs", "code"]), default="all")
80
+ @click.option("--clear", is_flag=True, help="Clear existing index before indexing")
81
+ def index(path: Path, index_type: str, clear: bool):
82
+ """Index a project directory."""
83
+ path = path.resolve()
84
+ db = get_db(path)
85
+ config = RagtimeConfig.load(path)
86
+
87
+ if clear:
88
+ click.echo("Clearing existing index...")
89
+ if index_type == "all":
90
+ db.clear()
91
+ else:
92
+ db.clear(type_filter=index_type)
93
+
94
+ # Index docs
95
+ if index_type in ("all", "docs"):
96
+ total_entries = []
97
+ for docs_path in config.docs.paths:
98
+ docs_root = path / docs_path
99
+ if not docs_root.exists():
100
+ click.echo(f" Docs path {docs_root} not found, skipping...")
101
+ continue
102
+ click.echo(f"Indexing docs in {docs_root}...")
103
+ entries = index_docs(
104
+ docs_root,
105
+ patterns=config.docs.patterns,
106
+ exclude=config.docs.exclude,
107
+ )
108
+ total_entries.extend(entries)
109
+
110
+ if total_entries:
111
+ ids = [e.file_path for e in total_entries]
112
+ documents = [e.content for e in total_entries]
113
+ metadatas = [e.to_metadata() for e in total_entries]
114
+
115
+ db.upsert(ids=ids, documents=documents, metadatas=metadatas)
116
+ click.echo(f" Indexed {len(total_entries)} documents")
117
+ else:
118
+ click.echo(" No documents found")
119
+
120
+ # Index code
121
+ if index_type in ("all", "code"):
122
+ # Build exclude list that includes docs paths
123
+ code_exclude = list(config.code.exclude)
124
+ for docs_path in config.docs.paths:
125
+ code_exclude.append(f"**/{docs_path}/**")
126
+
127
+ click.echo("Code indexing not yet implemented")
128
+ click.echo(f" Will exclude docs paths: {config.docs.paths}")
129
+ # TODO: Implement code indexer with code_exclude
130
+
131
+ # Show stats
132
+ stats = db.stats()
133
+ click.echo(f"\nIndex stats: {stats['total']} total ({stats['docs']} docs, {stats['code']} code)")
134
+
135
+
136
+ @main.command()
137
+ @click.argument("query")
138
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
139
+ @click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
140
+ @click.option("--namespace", "-n", help="Filter by namespace")
141
+ @click.option("--limit", "-l", default=5, help="Max results")
142
+ @click.option("--verbose", "-v", is_flag=True, help="Show full content")
143
+ def search(query: str, path: Path, type_filter: str, namespace: str, limit: int, verbose: bool):
144
+ """Search indexed content."""
145
+ path = Path(path).resolve()
146
+ db = get_db(path)
147
+
148
+ type_arg = None if type_filter == "all" else type_filter
149
+
150
+ results = db.search(
151
+ query=query,
152
+ limit=limit,
153
+ type_filter=type_arg,
154
+ namespace=namespace,
155
+ )
156
+
157
+ if not results:
158
+ click.echo("No results found.")
159
+ return
160
+
161
+ for i, result in enumerate(results, 1):
162
+ meta = result["metadata"]
163
+ distance = result["distance"]
164
+ score = 1 - distance if distance else None
165
+
166
+ click.echo(f"\n{'─' * 60}")
167
+ click.echo(f"[{i}] {meta.get('file', 'unknown')}")
168
+ click.echo(f" Type: {meta.get('type')} | Namespace: {meta.get('namespace', '-')}")
169
+ if score:
170
+ click.echo(f" Score: {score:.3f}")
171
+
172
+ if verbose:
173
+ click.echo(f"\n{result['content'][:500]}...")
174
+ else:
175
+ # Show first 150 chars
176
+ preview = result["content"][:150].replace("\n", " ")
177
+ click.echo(f" {preview}...")
178
+
179
+
180
+ @main.command()
181
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
182
+ def stats(path: Path):
183
+ """Show index statistics."""
184
+ path = Path(path).resolve()
185
+ db = get_db(path)
186
+
187
+ s = db.stats()
188
+ click.echo(f"Total indexed: {s['total']}")
189
+ click.echo(f" Docs: {s['docs']}")
190
+ click.echo(f" Code: {s['code']}")
191
+
192
+
193
+ @main.command()
194
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
195
+ @click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
196
+ @click.confirmation_option(prompt="Are you sure you want to clear the index?")
197
+ def clear(path: Path, type_filter: str):
198
+ """Clear the index."""
199
+ path = Path(path).resolve()
200
+ db = get_db(path)
201
+
202
+ if type_filter == "all":
203
+ db.clear()
204
+ click.echo("Index cleared.")
205
+ else:
206
+ db.clear(type_filter=type_filter)
207
+ click.echo(f"Cleared {type_filter} from index.")
208
+
209
+
210
+ @main.command()
211
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
212
+ def config(path: Path):
213
+ """Show current configuration."""
214
+ path = Path(path).resolve()
215
+ cfg = RagtimeConfig.load(path)
216
+
217
+ click.echo("Docs:")
218
+ click.echo(f" Paths: {cfg.docs.paths}")
219
+ click.echo(f" Patterns: {cfg.docs.patterns}")
220
+ click.echo(f" Exclude: {cfg.docs.exclude}")
221
+ click.echo("\nCode:")
222
+ click.echo(f" Paths: {cfg.code.paths}")
223
+ click.echo(f" Languages: {cfg.code.languages}")
224
+ click.echo(f" Exclude: {cfg.code.exclude}")
225
+
226
+
227
+ # ============================================================================
228
+ # Memory Storage Commands
229
+ # ============================================================================
230
+
231
+
232
+ @main.command()
233
+ @click.argument("content")
234
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
235
+ @click.option("--namespace", "-n", required=True, help="Namespace: app, team, user-{name}, branch-{name}")
236
+ @click.option("--type", "-t", "memory_type", required=True,
237
+ type=click.Choice(["architecture", "feature", "integration", "convention",
238
+ "preference", "decision", "pattern", "task-state", "handoff"]),
239
+ help="Memory type")
240
+ @click.option("--component", "-c", help="Component area (e.g., auth, claims, shifts)")
241
+ @click.option("--confidence", default="medium",
242
+ type=click.Choice(["high", "medium", "low"]),
243
+ help="Confidence level")
244
+ @click.option("--confidence-reason", help="Why this confidence level")
245
+ @click.option("--source", "-s", default="remember", help="Source of this memory")
246
+ @click.option("--issue", help="Related GitHub issue (e.g., #301)")
247
+ @click.option("--epic", help="Parent epic (e.g., #286)")
248
+ @click.option("--branch", help="Related branch name")
249
+ def remember(content: str, path: Path, namespace: str, memory_type: str,
250
+ component: str, confidence: str, confidence_reason: str,
251
+ source: str, issue: str, epic: str, branch: str):
252
+ """Store a memory with structured metadata.
253
+
254
+ Example:
255
+ ragtime remember "Auth uses JWT with 15-min expiry" \\
256
+ --namespace app --type architecture --component auth
257
+ """
258
+ path = Path(path).resolve()
259
+ store = get_memory_store(path)
260
+
261
+ memory = Memory(
262
+ content=content,
263
+ namespace=namespace,
264
+ type=memory_type,
265
+ component=component,
266
+ confidence=confidence,
267
+ confidence_reason=confidence_reason,
268
+ source=source,
269
+ author=get_author(),
270
+ issue=issue,
271
+ epic=epic,
272
+ branch=branch,
273
+ )
274
+
275
+ file_path = store.save(memory)
276
+ click.echo(f"✓ Memory saved: {memory.id}")
277
+ click.echo(f" File: {file_path.relative_to(path)}")
278
+ click.echo(f" Namespace: {namespace}")
279
+ click.echo(f" Type: {memory_type}")
280
+
281
+
282
+ @main.command("store-doc")
283
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
284
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
285
+ @click.option("--namespace", "-n", required=True, help="Namespace for the document")
286
+ @click.option("--type", "-t", "doc_type", default="handoff",
287
+ type=click.Choice(["handoff", "document", "plan", "notes"]),
288
+ help="Document type")
289
+ def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
290
+ """Store a document verbatim (like handoff.md).
291
+
292
+ Example:
293
+ ragtime store-doc .claude/handoff.md --namespace branch-feature/auth
294
+ """
295
+ path = Path(path).resolve()
296
+ file = Path(file).resolve()
297
+ store = get_memory_store(path)
298
+
299
+ memory = store.store_document(file, namespace, doc_type)
300
+ click.echo(f"✓ Document stored: {memory.id}")
301
+ click.echo(f" Source: {file.name}")
302
+ click.echo(f" Namespace: {namespace}")
303
+ click.echo(f" Type: {doc_type}")
304
+
305
+
306
+ @main.command()
307
+ @click.argument("memory_id")
308
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
309
+ @click.confirmation_option(prompt="Are you sure you want to delete this memory?")
310
+ def forget(memory_id: str, path: Path):
311
+ """Delete a memory by ID.
312
+
313
+ Example:
314
+ ragtime forget abc123
315
+ """
316
+ path = Path(path).resolve()
317
+ store = get_memory_store(path)
318
+
319
+ if store.delete(memory_id):
320
+ click.echo(f"✓ Memory {memory_id} deleted")
321
+ else:
322
+ click.echo(f"✗ Memory {memory_id} not found", err=True)
323
+
324
+
325
+ @main.command()
326
+ @click.argument("memory_id")
327
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
328
+ @click.option("--confidence", default="high",
329
+ type=click.Choice(["high", "medium", "low"]),
330
+ help="Confidence level for graduated memory")
331
+ def graduate(memory_id: str, path: Path, confidence: str):
332
+ """Graduate a branch memory to app namespace.
333
+
334
+ Copies the memory to app namespace with high confidence
335
+ and marks the original as graduated.
336
+
337
+ Example:
338
+ ragtime graduate abc123
339
+ """
340
+ path = Path(path).resolve()
341
+ store = get_memory_store(path)
342
+
343
+ try:
344
+ graduated = store.graduate(memory_id, confidence)
345
+ if graduated:
346
+ click.echo(f"✓ Memory graduated to app namespace")
347
+ click.echo(f" New ID: {graduated.id}")
348
+ click.echo(f" Original marked as: graduated")
349
+ else:
350
+ click.echo(f"✗ Memory {memory_id} not found", err=True)
351
+ except ValueError as e:
352
+ click.echo(f"✗ {e}", err=True)
353
+
354
+
355
+ @main.command("memories")
356
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
357
+ @click.option("--namespace", "-n", help="Filter by namespace (use * suffix for prefix match)")
358
+ @click.option("--type", "-t", "type_filter", help="Filter by type")
359
+ @click.option("--status", "-s", help="Filter by status (active, graduated, abandoned)")
360
+ @click.option("--component", "-c", help="Filter by component")
361
+ @click.option("--limit", "-l", default=20, help="Max results")
362
+ @click.option("--verbose", "-v", is_flag=True, help="Show full content")
363
+ def list_memories(path: Path, namespace: str, type_filter: str, status: str,
364
+ component: str, limit: int, verbose: bool):
365
+ """List memories with optional filters.
366
+
367
+ Examples:
368
+ ragtime memories --namespace app
369
+ ragtime memories --namespace branch-* --status active
370
+ ragtime memories --type decision --component auth
371
+ """
372
+ path = Path(path).resolve()
373
+ store = get_memory_store(path)
374
+
375
+ memories = store.list_memories(
376
+ namespace=namespace,
377
+ type_filter=type_filter,
378
+ status=status,
379
+ component=component,
380
+ limit=limit,
381
+ )
382
+
383
+ if not memories:
384
+ click.echo("No memories found.")
385
+ return
386
+
387
+ click.echo(f"Found {len(memories)} memories:\n")
388
+
389
+ for mem in memories:
390
+ click.echo(f"{'─' * 60}")
391
+ click.echo(f"[{mem.id}] {mem.namespace} / {mem.type}")
392
+ if mem.component:
393
+ click.echo(f" Component: {mem.component}")
394
+ click.echo(f" Status: {mem.status} | Confidence: {mem.confidence}")
395
+ click.echo(f" Added: {mem.added} | Source: {mem.source}")
396
+
397
+ if verbose:
398
+ click.echo(f"\n{mem.content[:500]}...")
399
+ else:
400
+ preview = mem.content[:100].replace("\n", " ")
401
+ click.echo(f" {preview}...")
402
+
403
+
404
+ @main.command()
405
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
406
+ def reindex(path: Path):
407
+ """Reindex all memory files.
408
+
409
+ Scans .claude/memory/ and adds any files not in the index.
410
+ """
411
+ path = Path(path).resolve()
412
+ store = get_memory_store(path)
413
+
414
+ count = store.reindex()
415
+ click.echo(f"✓ Reindexed {count} memory files")
416
+
417
+
418
+ # ============================================================================
419
+ # Command Installation
420
+ # ============================================================================
421
+
422
+
423
+ def get_commands_dir() -> Path:
424
+ """Get the directory containing bundled command templates."""
425
+ return Path(__file__).parent / "commands"
426
+
427
+
428
+ def get_available_commands() -> list[str]:
429
+ """List available command templates."""
430
+ commands_dir = get_commands_dir()
431
+ if not commands_dir.exists():
432
+ return []
433
+ return [f.stem for f in commands_dir.glob("*.md")]
434
+
435
+
436
+ @main.command("install")
437
+ @click.option("--global", "global_install", is_flag=True, help="Install to ~/.claude/commands/")
438
+ @click.option("--workspace", "workspace_install", is_flag=True, help="Install to .claude/commands/")
439
+ @click.option("--list", "list_commands", is_flag=True, help="List available commands")
440
+ @click.option("--force", is_flag=True, help="Overwrite existing commands without asking")
441
+ @click.argument("commands", nargs=-1)
442
+ def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
443
+ force: bool, commands: tuple):
444
+ """Install Claude command templates.
445
+
446
+ Examples:
447
+ ragtime install --list # List available commands
448
+ ragtime install --workspace # Install all to .claude/commands/
449
+ ragtime install --global remember recall # Install specific commands globally
450
+ """
451
+ available = get_available_commands()
452
+
453
+ if list_commands:
454
+ click.echo("Available commands:")
455
+ for cmd in available:
456
+ click.echo(f" - {cmd}")
457
+ return
458
+
459
+ # Determine target directory
460
+ if global_install and workspace_install:
461
+ click.echo("Error: Cannot specify both --global and --workspace", err=True)
462
+ return
463
+
464
+ if global_install:
465
+ target_dir = Path.home() / ".claude" / "commands"
466
+ elif workspace_install:
467
+ target_dir = Path.cwd() / ".claude" / "commands"
468
+ else:
469
+ # Default to workspace
470
+ target_dir = Path.cwd() / ".claude" / "commands"
471
+ click.echo("Installing to workspace (.claude/commands/)")
472
+ click.echo("Use --global for ~/.claude/commands/")
473
+
474
+ # Determine which commands to install
475
+ if commands:
476
+ to_install = [c for c in commands if c in available]
477
+ not_found = [c for c in commands if c not in available]
478
+ if not_found:
479
+ click.echo(f"Warning: Commands not found: {', '.join(not_found)}", err=True)
480
+ else:
481
+ to_install = available
482
+
483
+ if not to_install:
484
+ click.echo("No commands to install.")
485
+ return
486
+
487
+ # Create target directory
488
+ target_dir.mkdir(parents=True, exist_ok=True)
489
+
490
+ # Install each command
491
+ commands_dir = get_commands_dir()
492
+ installed = 0
493
+ skipped = 0
494
+
495
+ for cmd in to_install:
496
+ source = commands_dir / f"{cmd}.md"
497
+ target = target_dir / f"{cmd}.md"
498
+
499
+ if target.exists() and not force:
500
+ if click.confirm(f" {cmd}.md exists. Overwrite?", default=False):
501
+ target.write_text(source.read_text())
502
+ click.echo(f" ✓ {cmd}.md (overwritten)")
503
+ installed += 1
504
+ else:
505
+ click.echo(f" - {cmd}.md (skipped)")
506
+ skipped += 1
507
+ else:
508
+ target.write_text(source.read_text())
509
+ click.echo(f" ✓ {cmd}.md")
510
+ installed += 1
511
+
512
+ click.echo(f"\nInstalled {installed} commands to {target_dir}")
513
+ if skipped:
514
+ click.echo(f"Skipped {skipped} existing commands (use --force to overwrite)")
515
+
516
+ # Remind about MCP server setup
517
+ click.echo("\nTo use these commands, add ragtime MCP server to your Claude config:")
518
+ click.echo(' "ragtime": {"command": "ragtime-mcp", "args": ["--path", "."]}')
519
+
520
+
521
+ # ============================================================================
522
+ # Cross-Branch Sync Commands
523
+ # ============================================================================
524
+
525
+
526
+ def get_branch_slug(ref: str) -> str:
527
+ """Convert a git ref to a branch slug for folder naming."""
528
+ # Remove origin/ prefix if present
529
+ if ref.startswith("origin/"):
530
+ ref = ref[7:]
531
+ # Replace / with - for folder names
532
+ return ref.replace("/", "-")
533
+
534
+
535
+ @main.command()
536
+ @click.argument("ref")
537
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
538
+ def sync(ref: str, path: Path):
539
+ """Sync memories from a remote branch.
540
+
541
+ Fetches .claude/memory/branches/* from the specified git ref
542
+ and copies to a local (unmerged) folder for searching.
543
+
544
+ Examples:
545
+ ragtime sync origin/jm/feature-auth
546
+ ragtime sync origin/sm/fix-bug
547
+ """
548
+ import shutil
549
+ import tempfile
550
+
551
+ path = Path(path).resolve()
552
+ store = get_memory_store(path)
553
+
554
+ # Get the branch slug for folder naming
555
+ branch_slug = get_branch_slug(ref)
556
+ unmerged_dir = path / ".claude" / "memory" / "branches" / f"{branch_slug}(unmerged)"
557
+
558
+ click.echo(f"Syncing memories from {ref}...")
559
+
560
+ # Fetch the ref first
561
+ result = subprocess.run(
562
+ ["git", "fetch", "origin"],
563
+ cwd=path,
564
+ capture_output=True,
565
+ text=True,
566
+ )
567
+
568
+ # Check if the ref exists
569
+ result = subprocess.run(
570
+ ["git", "rev-parse", "--verify", ref],
571
+ cwd=path,
572
+ capture_output=True,
573
+ text=True,
574
+ )
575
+ if result.returncode != 0:
576
+ click.echo(f"✗ Ref not found: {ref}", err=True)
577
+ return
578
+
579
+ commit_hash = result.stdout.strip()[:8]
580
+
581
+ # Check if there are memory files in that ref
582
+ result = subprocess.run(
583
+ ["git", "ls-tree", "-r", "--name-only", ref, ".claude/memory/branches/"],
584
+ cwd=path,
585
+ capture_output=True,
586
+ text=True,
587
+ )
588
+
589
+ if result.returncode != 0 or not result.stdout.strip():
590
+ click.echo(f"✗ No memories found in {ref}", err=True)
591
+ return
592
+
593
+ files = result.stdout.strip().split("\n")
594
+ click.echo(f" Found {len(files)} memory files")
595
+
596
+ # Clear existing unmerged folder if it exists
597
+ if unmerged_dir.exists():
598
+ shutil.rmtree(unmerged_dir)
599
+
600
+ unmerged_dir.mkdir(parents=True, exist_ok=True)
601
+
602
+ # Extract each file
603
+ synced = 0
604
+ for file_path in files:
605
+ if not file_path.endswith(".md"):
606
+ continue
607
+
608
+ # Get file content from git
609
+ result = subprocess.run(
610
+ ["git", "show", f"{ref}:{file_path}"],
611
+ cwd=path,
612
+ capture_output=True,
613
+ text=True,
614
+ )
615
+
616
+ if result.returncode != 0:
617
+ continue
618
+
619
+ content = result.stdout
620
+
621
+ # Determine target path (flatten to unmerged folder)
622
+ # Original: .claude/memory/branches/sm-feature/abc.md
623
+ # Target: .claude/memory/branches/sm-feature(unmerged)/abc.md
624
+ filename = Path(file_path).name
625
+ target_path = unmerged_dir / filename
626
+
627
+ target_path.write_text(content)
628
+ synced += 1
629
+
630
+ # Index with pre-merge status
631
+ try:
632
+ memory = Memory.from_file(target_path)
633
+ memory.status = "pre-merge"
634
+ # Update the namespace to include (unmerged) marker
635
+ memory.namespace = f"branch-{branch_slug}(unmerged)"
636
+
637
+ store.db.upsert(
638
+ ids=[f"{memory.id}-unmerged"],
639
+ documents=[memory.content],
640
+ metadatas=[{
641
+ **memory.to_metadata(),
642
+ "status": "pre-merge",
643
+ "source_ref": ref,
644
+ "source_commit": commit_hash,
645
+ }],
646
+ )
647
+ except Exception as e:
648
+ click.echo(f" Warning: Could not index {filename}: {e}", err=True)
649
+
650
+ # Write source tracking file
651
+ source_file = unmerged_dir / ".source"
652
+ source_file.write_text(f"{ref} @ {commit_hash}\n")
653
+
654
+ click.echo(f"✓ Synced {synced} memories to {unmerged_dir.relative_to(path)}")
655
+ click.echo(f" Source: {ref} @ {commit_hash}")
656
+ click.echo(f"\nSearch with: ragtime search 'query' --namespace 'branch-{branch_slug}(unmerged)'")
657
+
658
+
659
+ @main.command()
660
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
661
+ @click.option("--dry-run", is_flag=True, help="Show what would be pruned without deleting")
662
+ def prune(path: Path, dry_run: bool):
663
+ """Remove stale (unmerged) memory folders.
664
+
665
+ Checks each (unmerged) folder and removes it if:
666
+ - The source branch was deleted
667
+ - The source branch was merged (PR closed)
668
+
669
+ Examples:
670
+ ragtime prune --dry-run # See what would be pruned
671
+ ragtime prune # Actually prune
672
+ """
673
+ import shutil
674
+
675
+ path = Path(path).resolve()
676
+ branches_dir = path / ".claude" / "memory" / "branches"
677
+
678
+ if not branches_dir.exists():
679
+ click.echo("No branches directory found.")
680
+ return
681
+
682
+ # Find all (unmerged) folders
683
+ unmerged_folders = [d for d in branches_dir.iterdir()
684
+ if d.is_dir() and d.name.endswith("(unmerged)")]
685
+
686
+ if not unmerged_folders:
687
+ click.echo("No (unmerged) folders to prune.")
688
+ return
689
+
690
+ click.echo(f"Checking {len(unmerged_folders)} (unmerged) folders...\n")
691
+
692
+ to_prune = []
693
+ to_keep = []
694
+
695
+ for folder in unmerged_folders:
696
+ source_file = folder / ".source"
697
+ if not source_file.exists():
698
+ # No source tracking - mark for pruning
699
+ to_prune.append((folder, "no source tracking"))
700
+ continue
701
+
702
+ source_info = source_file.read_text().strip()
703
+ ref = source_info.split(" @ ")[0] if " @ " in source_info else source_info
704
+
705
+ # Check if ref still exists
706
+ result = subprocess.run(
707
+ ["git", "rev-parse", "--verify", ref],
708
+ cwd=path,
709
+ capture_output=True,
710
+ text=True,
711
+ )
712
+
713
+ if result.returncode != 0:
714
+ # Ref doesn't exist - check if it was merged
715
+ # Extract branch name from ref (e.g., origin/jm/feature -> jm/feature)
716
+ branch_name = ref.replace("origin/", "") if ref.startswith("origin/") else ref
717
+
718
+ # Check if a PR was merged for this branch
719
+ pr_result = subprocess.run(
720
+ ["gh", "pr", "list", "--head", branch_name, "--state", "merged", "--json", "number"],
721
+ cwd=path,
722
+ capture_output=True,
723
+ text=True,
724
+ )
725
+
726
+ if pr_result.returncode == 0 and pr_result.stdout.strip() != "[]":
727
+ to_prune.append((folder, f"PR merged for {branch_name}"))
728
+ else:
729
+ to_prune.append((folder, f"branch deleted: {ref}"))
730
+ else:
731
+ to_keep.append((folder, ref))
732
+
733
+ # Report findings
734
+ if to_prune:
735
+ click.echo("Will prune:")
736
+ for folder, reason in to_prune:
737
+ click.echo(f" ✗ {folder.name} ({reason})")
738
+
739
+ if to_keep:
740
+ click.echo("\nKeeping (branch still active):")
741
+ for folder, ref in to_keep:
742
+ click.echo(f" ✓ {folder.name}")
743
+
744
+ if not to_prune:
745
+ click.echo("\nNothing to prune.")
746
+ return
747
+
748
+ # Actually prune if not dry-run
749
+ if dry_run:
750
+ click.echo(f"\n--dry-run: Would prune {len(to_prune)} folders")
751
+ else:
752
+ click.echo("")
753
+ db = get_db(path)
754
+
755
+ for folder, reason in to_prune:
756
+ # Remove from index
757
+ branch_slug = folder.name.replace("(unmerged)", "")
758
+ # Find and delete indexed memories for this folder
759
+ results = db.collection.get(
760
+ where={"namespace": f"branch-{folder.name}"}
761
+ )
762
+ if results["ids"]:
763
+ db.delete(results["ids"])
764
+
765
+ # Remove folder
766
+ shutil.rmtree(folder)
767
+ click.echo(f" Pruned: {folder.name}")
768
+
769
+ click.echo(f"\n✓ Pruned {len(to_prune)} folders")
770
+
771
+
772
+ if __name__ == "__main__":
773
+ main()