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.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/bin/dev-memory.js +590 -0
- package/hooks/README.md +84 -0
- package/hooks/codex-hooks.json +32 -0
- package/hooks/hooks.json +60 -0
- package/lib/assets/tidy_review.html +889 -0
- package/lib/dev_memory_branch.py +853 -0
- package/lib/dev_memory_capture.py +1343 -0
- package/lib/dev_memory_common.py +1934 -0
- package/lib/dev_memory_context.py +129 -0
- package/lib/dev_memory_graduate.py +282 -0
- package/lib/dev_memory_setup.py +256 -0
- package/lib/dev_memory_tidy.py +1622 -0
- package/lib/ui-app.html +1052 -0
- package/lib/ui-server.js +330 -0
- package/package.json +52 -0
- package/scripts/hooks/_common.py +456 -0
- package/scripts/hooks/pre_compact.py +21 -0
- package/scripts/hooks/session_end.py +35 -0
- package/scripts/hooks/session_start.py +51 -0
- package/scripts/hooks/stop.py +35 -0
- package/suite-manifest.json +26 -0
|
@@ -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())
|