dev-memory-cli 0.18.2

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.
@@ -0,0 +1,853 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dev-memory-branch: atomic ops for managing branch-scoped memory directories.
4
+
5
+ Subcommands (all flag-driven, JSON output, no interactivity):
6
+ list : enumerate candidate branches (git ∪ memory dirs) with metadata
7
+ inspect : detect skeleton/used state of a single branch's memory dir
8
+ rename : move <source> → <target> (source dir disappears)
9
+ fork : copy <source> → <target> (source dir kept; provenance recorded)
10
+ delete : remove a branch's memory dir
11
+ init : reset a branch's memory dir to a fresh template skeleton
12
+
13
+ Conflict handling (rename / fork / delete / init on used target):
14
+ default : abort
15
+ --force : delete target before the op
16
+ --backup : move target to branches/_archived/<branch>-<UTC>/ first
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import shutil
23
+ import sys
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ from dev_memory_common import (
30
+ AUTO_END,
31
+ AUTO_START,
32
+ PLACEHOLDER_MARKERS,
33
+ detect_branch,
34
+ detect_repo_identity,
35
+ detect_repo_root,
36
+ detect_worktree_base_branch,
37
+ ensure_branch_paths_exist,
38
+ get_storage_root,
39
+ git_lines,
40
+ is_worktree,
41
+ now_iso,
42
+ read_json,
43
+ sanitize_branch_name,
44
+ template_decisions,
45
+ template_glossary,
46
+ template_overview,
47
+ template_pending_promotion,
48
+ template_progress,
49
+ template_risks,
50
+ template_unsorted,
51
+ write_json,
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Template fingerprints
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def _template_for(name, branch_name):
60
+ """Return the canonical template content for a managed file."""
61
+ if name == "overview.md":
62
+ return template_overview(branch_name)
63
+ if name == "decisions.md":
64
+ return template_decisions(branch_name)
65
+ if name == "progress.md":
66
+ return template_progress(branch_name)
67
+ if name == "risks.md":
68
+ return template_risks(branch_name)
69
+ if name == "glossary.md":
70
+ return template_glossary(branch_name)
71
+ if name == "unsorted.md":
72
+ return template_unsorted()
73
+ if name == "pending-promotion.md":
74
+ return template_pending_promotion()
75
+ return None
76
+
77
+
78
+ SKELETON_FILES = (
79
+ "overview.md",
80
+ "decisions.md",
81
+ "progress.md",
82
+ "risks.md",
83
+ "glossary.md",
84
+ "unsorted.md",
85
+ "pending-promotion.md",
86
+ )
87
+
88
+
89
+ def _normalize_progress_for_compare(text):
90
+ """progress.md may have its auto-sync block refreshed by sync-working-tree
91
+ even on an otherwise untouched branch. Treat the auto-sync region as
92
+ irrelevant when judging skeleton state.
93
+ """
94
+ start = text.find(AUTO_START)
95
+ end = text.find(AUTO_END)
96
+ if start == -1 or end == -1 or end < start:
97
+ return text
98
+ return text[: start + len(AUTO_START)] + "\n_尚未同步_\n" + text[end:]
99
+
100
+
101
+ def _file_is_template(path, name, branch_name):
102
+ if not path.exists():
103
+ return True # missing file ≡ never deviated
104
+ template = _template_for(name, branch_name)
105
+ if template is None:
106
+ return True
107
+ actual = path.read_text(encoding="utf-8")
108
+ if name == "progress.md":
109
+ actual = _normalize_progress_for_compare(actual)
110
+ return actual.strip() == template.strip()
111
+
112
+
113
+ _BULLET_RE = re.compile(r"^\s*-\s+(.+?)\s*$")
114
+
115
+
116
+ def _count_meaningful_bullets(text):
117
+ """Count `- foo` lines that are not template placeholders."""
118
+ if not text:
119
+ return 0
120
+ count = 0
121
+ for line in text.splitlines():
122
+ m = _BULLET_RE.match(line)
123
+ if not m:
124
+ continue
125
+ body = m.group(1).strip()
126
+ if not body:
127
+ continue
128
+ if any(marker in body for marker in PLACEHOLDER_MARKERS):
129
+ continue
130
+ count += 1
131
+ return count
132
+
133
+
134
+ def _count_entries_for(branch_dir, branch_name):
135
+ """Estimate "how many things the user has captured" on this branch.
136
+
137
+ For each managed markdown file, count meaningful bullets in the actual
138
+ content and subtract the bullets that ship in the template (e.g. the
139
+ "分支: - <name>" metadata line). The result roughly tracks the number of
140
+ captured records, without depending on artifacts/history accounting.
141
+ """
142
+ total = 0
143
+ for name in SKELETON_FILES:
144
+ path = branch_dir / name
145
+ template = _template_for(name, branch_name)
146
+ if template is None:
147
+ continue
148
+ baseline = _count_meaningful_bullets(template)
149
+ if not path.exists():
150
+ continue
151
+ actual_text = path.read_text(encoding="utf-8")
152
+ if name == "progress.md":
153
+ actual_text = _normalize_progress_for_compare(actual_text)
154
+ actual = _count_meaningful_bullets(actual_text)
155
+ total += max(0, actual - baseline)
156
+ return total
157
+
158
+
159
+ def _branch_dir_for(repo_root, branch_name, storage_root, identity):
160
+ branch_key = sanitize_branch_name(branch_name)
161
+ return storage_root / identity["repo_key"] / "branches" / branch_key, branch_key
162
+
163
+
164
+ def inspect_branch_dir(branch_dir, branch_name):
165
+ """Inspect a single branch memory dir and return a structured snapshot.
166
+
167
+ Result fields:
168
+ exists : the directory itself is present
169
+ has_manifest : manifest.json present (i.e. lazy-init has run)
170
+ is_skeleton : every managed file matches its template (or is absent)
171
+ deviations : list of files that differ from their template
172
+ entry_count : artifacts/history/* count (rough usage signal)
173
+ last_updated : manifest.updated_at if present
174
+ """
175
+ snapshot = {
176
+ "branch": branch_name,
177
+ "branch_key": sanitize_branch_name(branch_name),
178
+ "path": str(branch_dir),
179
+ "exists": branch_dir.exists() and branch_dir.is_dir(),
180
+ "has_manifest": False,
181
+ "is_skeleton": True,
182
+ "deviations": [],
183
+ "entry_count": 0,
184
+ "last_updated": None,
185
+ }
186
+ if not snapshot["exists"]:
187
+ return snapshot
188
+ manifest_path = branch_dir / "manifest.json"
189
+ if manifest_path.exists():
190
+ snapshot["has_manifest"] = True
191
+ manifest = read_json(manifest_path) or {}
192
+ snapshot["last_updated"] = manifest.get("updated_at")
193
+ deviations = []
194
+ for name in SKELETON_FILES:
195
+ if not _file_is_template(branch_dir / name, name, branch_name):
196
+ deviations.append(name)
197
+ snapshot["deviations"] = deviations
198
+ snapshot["is_skeleton"] = len(deviations) == 0
199
+ snapshot["entry_count"] = _count_entries_for(branch_dir, branch_name)
200
+ return snapshot
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Discovery
205
+ # ---------------------------------------------------------------------------
206
+
207
+ def _list_git_branches(repo_root):
208
+ try:
209
+ return git_lines(["branch", "--format=%(refname:short)"], cwd=repo_root, check=False)
210
+ except Exception:
211
+ return []
212
+
213
+
214
+ def _list_memory_branches(branches_root):
215
+ if not branches_root.exists():
216
+ return []
217
+ out = []
218
+ try:
219
+ for entry in sorted(branches_root.iterdir(), key=lambda p: p.name):
220
+ if entry.is_dir() and not entry.name.startswith("_"):
221
+ out.append(entry.name)
222
+ except OSError:
223
+ return []
224
+ return out
225
+
226
+
227
+ def _branch_key_to_display_name(branch_key, branches_root):
228
+ """Recover the original branch name from manifest.json if available;
229
+ otherwise fall back to the un-sanitized key (replacing '__' → '/')."""
230
+ manifest = read_json(branches_root / branch_key / "manifest.json") or {}
231
+ if manifest.get("branch"):
232
+ return manifest["branch"]
233
+ return branch_key.replace("__", "/")
234
+
235
+
236
+ def cmd_list(args):
237
+ repo_root = detect_repo_root(args.repo or ".")
238
+ storage_root = get_storage_root(repo_root, args.context_dir)
239
+ identity = detect_repo_identity(repo_root)
240
+ branches_root = storage_root / identity["repo_key"] / "branches"
241
+
242
+ current_branch = None
243
+ try:
244
+ current_branch = detect_branch(repo_root)
245
+ except Exception:
246
+ current_branch = None
247
+
248
+ seen = {}
249
+ for name in _list_git_branches(repo_root):
250
+ seen[name] = {"name": name, "git_exists": True, "memory_exists": False}
251
+ for key in _list_memory_branches(branches_root):
252
+ display = _branch_key_to_display_name(key, branches_root)
253
+ if display in seen:
254
+ seen[display]["memory_exists"] = True
255
+ else:
256
+ seen[display] = {"name": display, "git_exists": False, "memory_exists": True}
257
+
258
+ rows = []
259
+ for name in sorted(seen.keys()):
260
+ entry = seen[name]
261
+ branch_dir, _ = _branch_dir_for(repo_root, name, storage_root, identity)
262
+ snapshot = inspect_branch_dir(branch_dir, name)
263
+ rows.append({
264
+ "name": name,
265
+ "git_exists": entry["git_exists"],
266
+ "memory_exists": snapshot["exists"],
267
+ "is_current": name == current_branch,
268
+ "is_skeleton": snapshot["is_skeleton"],
269
+ "deviations": snapshot["deviations"],
270
+ "entry_count": snapshot["entry_count"],
271
+ "last_updated": snapshot["last_updated"],
272
+ })
273
+
274
+ print(json.dumps({
275
+ "repo_root": str(repo_root),
276
+ "storage_root": str(storage_root),
277
+ "repo_key": identity["repo_key"],
278
+ "current_branch": current_branch,
279
+ "branches": rows,
280
+ }, ensure_ascii=False, indent=2))
281
+ return 0
282
+
283
+
284
+ def cmd_inspect(args):
285
+ repo_root = detect_repo_root(args.repo or ".")
286
+ storage_root = get_storage_root(repo_root, args.context_dir)
287
+ identity = detect_repo_identity(repo_root)
288
+ branch_name = args.branch or detect_branch(repo_root)
289
+ branch_dir, _ = _branch_dir_for(repo_root, branch_name, storage_root, identity)
290
+ print(json.dumps(inspect_branch_dir(branch_dir, branch_name), ensure_ascii=False, indent=2))
291
+ return 0
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Conflict resolution
296
+ # ---------------------------------------------------------------------------
297
+
298
+ # /tmp safety net for --force. Even when the user opted to skip the proper
299
+ # _archived/ backup, we still copy the doomed dir here before rmtree so a
300
+ # fresh "oops" within the same boot cycle is recoverable. /tmp gets wiped by
301
+ # the OS eventually, which matches the user's "ephemeral is fine" intent.
302
+ FORCE_SAFETY_ROOT = Path("/tmp/dev-memory-force-backup")
303
+
304
+
305
+ def _archive_path(branches_root, target_branch_key):
306
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
307
+ return branches_root / "_archived" / f"{target_branch_key}-{stamp}"
308
+
309
+
310
+ def _force_safety_path(repo_key, target_branch_key):
311
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
312
+ return FORCE_SAFETY_ROOT / repo_key / f"{target_branch_key}-{stamp}"
313
+
314
+
315
+ def _force_destroy(target_dir, repo_key):
316
+ """rmtree, but copy to /tmp first so an accidental --force is recoverable.
317
+ Returns the safety-net path so callers can surface it to the user."""
318
+ safety = _force_safety_path(repo_key, target_dir.name)
319
+ safety.parent.mkdir(parents=True, exist_ok=True)
320
+ shutil.copytree(str(target_dir), str(safety))
321
+ shutil.rmtree(target_dir)
322
+ return safety
323
+
324
+
325
+ def _resolve_conflict(target_dir, target_snapshot, mode, repo_key):
326
+ """Return (None, safety_path|None) on success, or (reason, None) to abort.
327
+
328
+ safety_path is set when --force was honored — it points to the /tmp copy
329
+ we squirreled away before rmtree.
330
+ """
331
+ if not target_snapshot["exists"]:
332
+ return None, None
333
+ if target_snapshot["is_skeleton"]:
334
+ # Empty skeleton: silently overwrite, regardless of mode.
335
+ shutil.rmtree(target_dir)
336
+ return None, None
337
+ # Used target.
338
+ if mode == "force":
339
+ safety = _force_destroy(target_dir, repo_key)
340
+ return None, safety
341
+ if mode == "backup":
342
+ archive = _archive_path(target_dir.parent, target_dir.name)
343
+ archive.parent.mkdir(parents=True, exist_ok=True)
344
+ shutil.move(str(target_dir), str(archive))
345
+ return None, None
346
+ reason = (
347
+ f"target branch '{target_snapshot['branch']}' already has memory "
348
+ f"({len(target_snapshot['deviations'])} files diverged from template). "
349
+ f"Pass --force or --backup to proceed."
350
+ )
351
+ return reason, None
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Manifest update
356
+ # ---------------------------------------------------------------------------
357
+
358
+ def _rewrite_manifest(branch_dir, new_branch_name, source_branch_name=None, op=None):
359
+ manifest_path = branch_dir / "manifest.json"
360
+ manifest = read_json(manifest_path) or {}
361
+ new_key = sanitize_branch_name(new_branch_name)
362
+ manifest["branch"] = new_branch_name
363
+ manifest["branch_key"] = new_key
364
+ manifest["updated_at"] = now_iso()
365
+ if op:
366
+ prov = manifest.get("provenance") or []
367
+ if isinstance(prov, list):
368
+ prov.append({
369
+ "op": op,
370
+ "from": source_branch_name,
371
+ "at": manifest["updated_at"],
372
+ })
373
+ manifest["provenance"] = prov
374
+ write_json(manifest_path, manifest)
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Mechanical metadata rewrite (after fork/rename)
379
+ # ---------------------------------------------------------------------------
380
+
381
+ # 5 of the 7 managed files have a "## 分支" / "- <branch>" self-identifier
382
+ # stamped by their template. After fork/rename the bullet still reads as the
383
+ # source branch — we rewrite it to the new branch so the file no longer claims
384
+ # to belong to the old one.
385
+ _BRANCH_SECTION_FILES = (
386
+ "overview.md",
387
+ "decisions.md",
388
+ "progress.md",
389
+ "risks.md",
390
+ "glossary.md",
391
+ )
392
+
393
+ _BRANCH_SECTION_RE = re.compile(
394
+ r"(^## 分支\s*\n+\s*-\s+)([^\n]+)",
395
+ re.MULTILINE,
396
+ )
397
+
398
+
399
+ def _rewrite_branch_self_identifier(path, new_branch_name):
400
+ if not path.exists():
401
+ return False
402
+ text = path.read_text(encoding="utf-8")
403
+ new_text, n = _BRANCH_SECTION_RE.subn(
404
+ lambda m: f"{m.group(1)}{new_branch_name}",
405
+ text,
406
+ count=1,
407
+ )
408
+ if n == 0:
409
+ return False
410
+ path.write_text(new_text, encoding="utf-8")
411
+ return True
412
+
413
+
414
+ def _reset_progress_auto_sync(branch_dir):
415
+ """The auto-sync block holds git facts (HEAD, changed paths, commits) for
416
+ the *source* branch at fork/rename time. After transfer those facts are
417
+ stale; reset to the placeholder so the next `capture sync-working-tree`
418
+ repopulates from the new branch."""
419
+ progress = branch_dir / "progress.md"
420
+ if not progress.exists():
421
+ return
422
+ text = progress.read_text(encoding="utf-8")
423
+ start = text.find(AUTO_START)
424
+ end = text.find(AUTO_END)
425
+ if start == -1 or end == -1 or end < start:
426
+ return
427
+ rebuilt = (
428
+ text[: start + len(AUTO_START)]
429
+ + "\n_尚未同步_\n"
430
+ + text[end:]
431
+ )
432
+ progress.write_text(rebuilt, encoding="utf-8")
433
+
434
+
435
+ def _rewrite_branch_metadata(branch_dir, new_branch_name):
436
+ for name in _BRANCH_SECTION_FILES:
437
+ _rewrite_branch_self_identifier(branch_dir / name, new_branch_name)
438
+ _reset_progress_auto_sync(branch_dir)
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Provenance note (overview.md)
443
+ # ---------------------------------------------------------------------------
444
+
445
+ PROVENANCE_HEADER = "## 分支起源"
446
+
447
+
448
+ def _provenance_block(source_branch_name, op):
449
+ if op == "rename":
450
+ verb = "renamed"
451
+ suffix = "原分支已不存在(仅改名)"
452
+ elif op == "worktree-inherit":
453
+ verb = "auto-inherited (worktree)"
454
+ suffix = "Worktree 首次 lazy-init 时自动从源分支拉取记忆;源分支保留"
455
+ else:
456
+ verb = "forked"
457
+ suffix = "原分支保留;本分支为独立延伸"
458
+ return (
459
+ f"{PROVENANCE_HEADER}\n\n"
460
+ f"- {verb} from `{source_branch_name}` at {now_iso()}\n"
461
+ f"- 模板锚定字段已重写为新分支名;用户自由文本未改,引用以源分支语境为准\n"
462
+ f"- {suffix}\n"
463
+ )
464
+
465
+
466
+ def _stamp_overview_provenance(branch_dir, source_branch_name, op):
467
+ """Insert (or replace) the 分支起源 section in overview.md right after the
468
+ "## 分支" self-identifier, so cold-start readers see who/where-from on the
469
+ same screen."""
470
+ overview = branch_dir / "overview.md"
471
+ if not overview.exists():
472
+ return
473
+ text = overview.read_text(encoding="utf-8")
474
+ block = _provenance_block(source_branch_name, op)
475
+
476
+ # Strip any pre-existing 分支起源 section so re-running fork/rename
477
+ # doesn't accumulate duplicates.
478
+ text = re.sub(
479
+ r"## 分支起源\s*\n.*?(?=^## |\Z)",
480
+ "",
481
+ text,
482
+ flags=re.MULTILINE | re.DOTALL,
483
+ ).rstrip() + "\n"
484
+
485
+ # Insert immediately after the "## 分支\n\n- <name>" block.
486
+ insert_re = re.compile(
487
+ r"(^## 分支\s*\n+\s*-\s+[^\n]+\n+)",
488
+ re.MULTILINE,
489
+ )
490
+ if insert_re.search(text):
491
+ new_text = insert_re.sub(lambda m: m.group(1) + "\n" + block + "\n", text, count=1)
492
+ else:
493
+ # Fallback: append at end.
494
+ new_text = text.rstrip() + "\n\n" + block
495
+ overview.write_text(new_text, encoding="utf-8")
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # rename / fork
500
+ # ---------------------------------------------------------------------------
501
+
502
+ def _resolve_conflict_mode(args):
503
+ if getattr(args, "force", False):
504
+ return "force"
505
+ if getattr(args, "backup", False):
506
+ return "backup"
507
+ return "abort"
508
+
509
+
510
+ def _do_transfer(args, *, op):
511
+ repo_root = detect_repo_root(args.repo or ".")
512
+ storage_root = get_storage_root(repo_root, args.context_dir)
513
+ identity = detect_repo_identity(repo_root)
514
+ source_name = args.source
515
+ target_name = args.target
516
+ if not source_name or not target_name:
517
+ raise ValueError("both --source and --target are required")
518
+ if source_name == target_name:
519
+ raise ValueError("source and target are identical")
520
+
521
+ source_dir, source_key = _branch_dir_for(repo_root, source_name, storage_root, identity)
522
+ target_dir, target_key = _branch_dir_for(repo_root, target_name, storage_root, identity)
523
+
524
+ source_snapshot = inspect_branch_dir(source_dir, source_name)
525
+ if not source_snapshot["exists"]:
526
+ raise ValueError(f"source branch '{source_name}' has no memory dir at {source_dir}")
527
+ if source_snapshot["is_skeleton"] and not args.allow_empty_source:
528
+ raise ValueError(
529
+ f"source branch '{source_name}' is an empty skeleton — nothing to transfer. "
530
+ f"Pass --allow-empty-source to proceed anyway."
531
+ )
532
+
533
+ target_snapshot = inspect_branch_dir(target_dir, target_name)
534
+ abort_reason, safety = _resolve_conflict(
535
+ target_dir, target_snapshot, _resolve_conflict_mode(args), identity["repo_key"],
536
+ )
537
+ if abort_reason:
538
+ raise RuntimeError(abort_reason)
539
+
540
+ target_dir.parent.mkdir(parents=True, exist_ok=True)
541
+
542
+ if op == "rename":
543
+ shutil.move(str(source_dir), str(target_dir))
544
+ elif op == "fork":
545
+ shutil.copytree(str(source_dir), str(target_dir))
546
+ else:
547
+ raise ValueError(f"unknown op: {op}")
548
+
549
+ _rewrite_manifest(target_dir, target_name, source_branch_name=source_name, op=op)
550
+ _rewrite_branch_metadata(target_dir, target_name)
551
+ _stamp_overview_provenance(target_dir, source_name, op)
552
+
553
+ result = {
554
+ "op": op,
555
+ "source": source_name,
556
+ "target": target_name,
557
+ "source_dir": str(source_dir),
558
+ "target_dir": str(target_dir),
559
+ "source_branch_key": source_key,
560
+ "target_branch_key": target_key,
561
+ }
562
+ if safety:
563
+ result["force_safety_backup"] = str(safety)
564
+ return result
565
+
566
+
567
+ def cmd_rename(args):
568
+ result = _do_transfer(args, op="rename")
569
+ print(json.dumps(result, ensure_ascii=False, indent=2))
570
+ return 0
571
+
572
+
573
+ def cmd_fork(args):
574
+ result = _do_transfer(args, op="fork")
575
+ print(json.dumps(result, ensure_ascii=False, indent=2))
576
+ return 0
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # delete / init
581
+ # ---------------------------------------------------------------------------
582
+
583
+ def _resolve_target_branch(args):
584
+ repo_root = detect_repo_root(args.repo or ".")
585
+ storage_root = get_storage_root(repo_root, args.context_dir)
586
+ identity = detect_repo_identity(repo_root)
587
+ branch_name = args.branch or detect_branch(repo_root)
588
+ branch_dir, branch_key = _branch_dir_for(repo_root, branch_name, storage_root, identity)
589
+ return repo_root, storage_root, identity, branch_name, branch_key, branch_dir
590
+
591
+
592
+ def cmd_delete(args):
593
+ repo_root, _, identity, branch_name, branch_key, branch_dir = _resolve_target_branch(args)
594
+ snapshot = inspect_branch_dir(branch_dir, branch_name)
595
+ if not snapshot["exists"]:
596
+ print(json.dumps({
597
+ "op": "delete",
598
+ "branch": branch_name,
599
+ "branch_key": branch_key,
600
+ "branch_dir": str(branch_dir),
601
+ "mode": "noop",
602
+ "detail": "branch has no memory dir",
603
+ }, ensure_ascii=False, indent=2))
604
+ return 0
605
+ mode = _resolve_conflict_mode(args)
606
+ if snapshot["is_skeleton"]:
607
+ # Empty skeleton has nothing to lose — just remove and report.
608
+ shutil.rmtree(branch_dir)
609
+ print(json.dumps({
610
+ "op": "delete",
611
+ "branch": branch_name,
612
+ "branch_key": branch_key,
613
+ "branch_dir": str(branch_dir),
614
+ "mode": mode if mode != "abort" else "skeleton",
615
+ "entry_count_before": 0,
616
+ }, ensure_ascii=False, indent=2))
617
+ return 0
618
+ if mode == "abort":
619
+ raise RuntimeError(
620
+ f"branch '{branch_name}' has {snapshot['entry_count']} memory entries. "
621
+ f"Pass --backup (move to _archived/) or --force (delete outright)."
622
+ )
623
+ abort_reason, safety = _resolve_conflict(branch_dir, snapshot, mode, identity["repo_key"])
624
+ if abort_reason:
625
+ raise RuntimeError(abort_reason)
626
+ result = {
627
+ "op": "delete",
628
+ "branch": branch_name,
629
+ "branch_key": branch_key,
630
+ "branch_dir": str(branch_dir),
631
+ "mode": mode,
632
+ "entry_count_before": snapshot["entry_count"],
633
+ }
634
+ if safety:
635
+ result["force_safety_backup"] = str(safety)
636
+ print(json.dumps(result, ensure_ascii=False, indent=2))
637
+ return 0
638
+
639
+
640
+ def cmd_init(args):
641
+ repo_root, _, identity, branch_name, branch_key, branch_dir = _resolve_target_branch(args)
642
+ snapshot = inspect_branch_dir(branch_dir, branch_name)
643
+ # If branch is already a fresh skeleton (or never initialized) just lazy-init
644
+ # in place so the caller gets a deterministic clean dir without needing flags.
645
+ if not snapshot["exists"] or snapshot["is_skeleton"]:
646
+ ensure_branch_paths_exist(str(repo_root), context_dir=args.context_dir, branch=branch_name)
647
+ print(json.dumps({
648
+ "op": "init",
649
+ "branch": branch_name,
650
+ "branch_key": branch_key,
651
+ "branch_dir": str(branch_dir),
652
+ "mode": "noop",
653
+ "detail": "branch was already empty; skeleton ensured",
654
+ }, ensure_ascii=False, indent=2))
655
+ return 0
656
+ mode = _resolve_conflict_mode(args)
657
+ if mode == "abort":
658
+ raise RuntimeError(
659
+ f"branch '{branch_name}' has {snapshot['entry_count']} memory entries that would be wiped. "
660
+ f"Pass --force or --backup to proceed."
661
+ )
662
+ abort_reason, safety = _resolve_conflict(branch_dir, snapshot, mode, identity["repo_key"])
663
+ if abort_reason:
664
+ raise RuntimeError(abort_reason)
665
+ # Re-create the skeleton in place.
666
+ ensure_branch_paths_exist(str(repo_root), context_dir=args.context_dir, branch=branch_name)
667
+ result = {
668
+ "op": "init",
669
+ "branch": branch_name,
670
+ "branch_key": branch_key,
671
+ "branch_dir": str(branch_dir),
672
+ "mode": mode,
673
+ "entry_count_before": snapshot["entry_count"],
674
+ }
675
+ if safety:
676
+ result["force_safety_backup"] = str(safety)
677
+ print(json.dumps(result, ensure_ascii=False, indent=2))
678
+ return 0
679
+
680
+
681
+ # ---------------------------------------------------------------------------
682
+ # inherit-worktree-base
683
+ # ---------------------------------------------------------------------------
684
+
685
+ def cmd_inherit_worktree_base(args):
686
+ """Explicit user-triggered worktree memory inheritance.
687
+
688
+ Mirrors what lazy-init does automatically on first touch, but usable
689
+ *after* the worktree session already started (auto-inherit only fires
690
+ when branch_dir didn't yet exist). Detects the source branch from reflog
691
+ by default; --source overrides.
692
+
693
+ Conflict handling on a non-empty target follows the same flags as fork:
694
+ --backup (recommended), --force, default abort.
695
+ """
696
+ repo_root = detect_repo_root(args.repo or ".")
697
+ storage_root = get_storage_root(repo_root, args.context_dir)
698
+ identity = detect_repo_identity(repo_root)
699
+ target_name = args.branch or detect_branch(repo_root)
700
+ target_dir, target_key = _branch_dir_for(repo_root, target_name, storage_root, identity)
701
+
702
+ in_worktree = False
703
+ try:
704
+ in_worktree = is_worktree(repo_root)
705
+ except Exception:
706
+ in_worktree = False
707
+ if not in_worktree and not args.allow_non_worktree:
708
+ raise RuntimeError(
709
+ f"'{repo_root}' is not a linked worktree. Pass --allow-non-worktree "
710
+ f"if you really want to inherit memory into a main-repo checkout."
711
+ )
712
+
713
+ source_name = args.source or detect_worktree_base_branch(repo_root, target_name)
714
+ if not source_name:
715
+ raise RuntimeError(
716
+ f"could not auto-detect the base branch for '{target_name}' from reflog. "
717
+ f"Pass --source <branch> explicitly. (Common cause: the worktree was "
718
+ f"created without -b, or the reflog rolled off.)"
719
+ )
720
+ if source_name == target_name:
721
+ raise ValueError("source and target are identical")
722
+
723
+ source_dir, source_key = _branch_dir_for(repo_root, source_name, storage_root, identity)
724
+ source_snapshot = inspect_branch_dir(source_dir, source_name)
725
+ if not source_snapshot["exists"]:
726
+ raise ValueError(f"source branch '{source_name}' has no memory dir at {source_dir}")
727
+ if source_snapshot["is_skeleton"] and not args.allow_empty_source:
728
+ raise ValueError(
729
+ f"source branch '{source_name}' is an empty skeleton — nothing to inherit. "
730
+ f"Pass --allow-empty-source to proceed anyway."
731
+ )
732
+
733
+ target_snapshot = inspect_branch_dir(target_dir, target_name)
734
+ abort_reason, safety = _resolve_conflict(
735
+ target_dir, target_snapshot, _resolve_conflict_mode(args), identity["repo_key"],
736
+ )
737
+ if abort_reason:
738
+ raise RuntimeError(abort_reason)
739
+
740
+ target_dir.parent.mkdir(parents=True, exist_ok=True)
741
+ shutil.copytree(str(source_dir), str(target_dir))
742
+ _rewrite_manifest(target_dir, target_name, source_branch_name=source_name, op="worktree-inherit")
743
+ _rewrite_branch_metadata(target_dir, target_name)
744
+ _stamp_overview_provenance(target_dir, source_name, "worktree-inherit")
745
+
746
+ result = {
747
+ "op": "inherit-worktree-base",
748
+ "source": source_name,
749
+ "target": target_name,
750
+ "source_dir": str(source_dir),
751
+ "target_dir": str(target_dir),
752
+ "source_branch_key": source_key,
753
+ "target_branch_key": target_key,
754
+ "source_detected_via": "reflog" if not args.source else "user",
755
+ }
756
+ if safety:
757
+ result["force_safety_backup"] = str(safety)
758
+ print(json.dumps(result, ensure_ascii=False, indent=2))
759
+ return 0
760
+
761
+
762
+ # ---------------------------------------------------------------------------
763
+ # Argparse wiring
764
+ # ---------------------------------------------------------------------------
765
+
766
+ def _add_common_args(parser):
767
+ parser.add_argument("--repo", help="Repo root (defaults to cwd)")
768
+ parser.add_argument("--context-dir", help="Storage root (defaults to ~/.dev-memory/repos)")
769
+
770
+
771
+ def _add_transfer_args(parser):
772
+ _add_common_args(parser)
773
+ parser.add_argument("--source", required=True, help="Source branch name")
774
+ parser.add_argument("--target", required=True, help="Target branch name")
775
+ parser.add_argument("--force", action="store_true", help="Overwrite target if it has content")
776
+ parser.add_argument("--backup", action="store_true", help="Move target to _archived/ before overwriting")
777
+ parser.add_argument(
778
+ "--allow-empty-source",
779
+ action="store_true",
780
+ help="Proceed even if source is an empty skeleton",
781
+ )
782
+
783
+
784
+ def main():
785
+ parser = argparse.ArgumentParser(
786
+ description="Move or clone branch-scoped dev-memory between branches.",
787
+ )
788
+ subparsers = parser.add_subparsers(dest="command", required=True)
789
+
790
+ list_p = subparsers.add_parser("list", help="Enumerate branches with memory metadata")
791
+ _add_common_args(list_p)
792
+
793
+ inspect_p = subparsers.add_parser("inspect", help="Inspect a single branch's memory state")
794
+ _add_common_args(inspect_p)
795
+ inspect_p.add_argument("--branch", help="Branch name (defaults to current)")
796
+
797
+ rename_p = subparsers.add_parser("rename", help="Move source memory dir onto target")
798
+ _add_transfer_args(rename_p)
799
+
800
+ fork_p = subparsers.add_parser("fork", help="Copy source memory dir onto target")
801
+ _add_transfer_args(fork_p)
802
+
803
+ delete_p = subparsers.add_parser("delete", help="Delete a branch's memory dir")
804
+ _add_common_args(delete_p)
805
+ delete_p.add_argument("--branch", help="Branch name (defaults to current)")
806
+ delete_p.add_argument("--force", action="store_true", help="Delete outright")
807
+ delete_p.add_argument("--backup", action="store_true", help="Move to _archived/ instead of deleting")
808
+
809
+ init_p = subparsers.add_parser("init", help="Reset a branch's memory dir to a fresh skeleton")
810
+ _add_common_args(init_p)
811
+ init_p.add_argument("--branch", help="Branch name (defaults to current)")
812
+ init_p.add_argument("--force", action="store_true", help="Wipe existing content before re-creating skeleton")
813
+ init_p.add_argument("--backup", action="store_true", help="Move existing content to _archived/ before re-creating")
814
+
815
+ inherit_p = subparsers.add_parser(
816
+ "inherit-worktree-base",
817
+ help="Copy the worktree's base-branch memory into the current branch's slot",
818
+ )
819
+ _add_common_args(inherit_p)
820
+ inherit_p.add_argument("--branch", help="Target branch (defaults to current)")
821
+ inherit_p.add_argument("--source", help="Source branch to inherit from (defaults to reflog detection)")
822
+ inherit_p.add_argument("--force", action="store_true", help="Overwrite target even if it already has content")
823
+ inherit_p.add_argument("--backup", action="store_true", help="Move existing target to _archived/ first")
824
+ inherit_p.add_argument(
825
+ "--allow-empty-source",
826
+ action="store_true",
827
+ help="Proceed even if source branch memory is an empty skeleton",
828
+ )
829
+ inherit_p.add_argument(
830
+ "--allow-non-worktree",
831
+ action="store_true",
832
+ help="Allow running this in the main repo checkout (not a linked worktree)",
833
+ )
834
+
835
+ args = parser.parse_args()
836
+ handlers = {
837
+ "list": cmd_list,
838
+ "inspect": cmd_inspect,
839
+ "rename": cmd_rename,
840
+ "fork": cmd_fork,
841
+ "delete": cmd_delete,
842
+ "init": cmd_init,
843
+ "inherit-worktree-base": cmd_inherit_worktree_base,
844
+ }
845
+ try:
846
+ return handlers[args.command](args) or 0
847
+ except Exception as exc:
848
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
849
+ return 1
850
+
851
+
852
+ if __name__ == "__main__":
853
+ sys.exit(main())