researchtree 1.0.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.
- researchtree/__init__.py +13 -0
- researchtree/__main__.py +3 -0
- researchtree/cli.py +192 -0
- researchtree/experiment/__init__.py +1 -0
- researchtree/experiment/body.py +266 -0
- researchtree/experiment/release.py +171 -0
- researchtree/experiment/tracking.py +99 -0
- researchtree/git.py +34 -0
- researchtree/github/__init__.py +1 -0
- researchtree/github/api.py +108 -0
- researchtree/github/auth.py +167 -0
- researchtree/github/tokens.py +85 -0
- researchtree/memory/__init__.py +86 -0
- researchtree/memory/build.py +352 -0
- researchtree/memory/manifest.py +51 -0
- researchtree/memory/model.py +677 -0
- researchtree/memory/rules.py +105 -0
- researchtree/memory/source.py +118 -0
- researchtree/server/__init__.py +24 -0
- researchtree/server/app.py +50 -0
- researchtree/server/handler.py +266 -0
- researchtree/server/static/assets/PretendardVariable.subset.0-BHUkWNFR.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.1-C-__qv6_.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.10-DzSWztS8.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.11-CqVmlKJn.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.12-BHuZSgT0.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.13-C42mj_j2.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.14-Bl512uUX.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.15-D04iXIE3.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.16-BQUnS2GX.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.17-BfZSA-Xc.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.18-CwAxMC3C.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.19-CJu4Zcdo.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.2-dCZkyKLw.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.20-Ig1-z3n5.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.21-yKPEdLXC.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.22-CSqxKoOs.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.23-DK80wi0t.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.24-CmkE8Q8D.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.25-CsoWBIZB.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.26-Sozl8dw8.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.27-CT6nuW9L.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.28-CpO0Y96p.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.29-D6hjrUWm.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.3-Dqw33sf4.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.30-CWDM1c0J.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.31-CdmyZ5mm.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.32-CGnFWD2i.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.33--0OT__YQ.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.34-CaCS33Md.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.35-DWFYRGLp.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.36-Dn5IBRQB.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.37-BD6FyOtY.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.38-D4hu443z.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.39-B_7wfth9.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.4-Bvh2YGoc.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.40-BDaOfdUe.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.41-BUACvzZC.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.42-Dp-5mnyL.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.43-DHdpry7N.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.44-qHopVhdd.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.45-BniyRFfm.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.46-BMRq7xC-.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.47-B-cWO2pw.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.48-Ct-fWrPO.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.49-BblQVys9.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.5-K_MNGNCe.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.50-C8IyFH7L.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.51-Bxd0gTAs.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.52-CNgqKOOJ.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.53-BSRnyb-u.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.54-Dt2-cQkx.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.55-jFgflYjX.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.56-BwZdvJZQ.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.57-BwFDg-Fs.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.58-DlucQts_.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.59-CMkWjhdo.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.6-Bxhohlcm.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.60-CeHezjjf.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.61-PUuTnod4.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.62-DGSAWCfb.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.63-B35xsm4O.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.64-CTbrgYF9.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.65-B66rjuyf.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.66-C3HqaDeY.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.67-BmuXdlDy.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.68-DS9B48d0.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.69-YT16ymcp.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.7-E2HaA55t.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.70-BUXiAGMT.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.71-DuPZj8us.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.72-pYYGrEQR.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.73-DMrK970F.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.74-D4tQnymK.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.75-CxKdrRNf.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.76-DhPm2b_q.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.77-DwaxqOC8.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.78-DhqRbBzT.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.79-XpoyPP38.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.8-CRbJhhyA.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.80-DsV9Qp_h.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.81-BZzF9Hb3.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.82-BgAHe30u.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.83-DF-zBLLe.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.84-Brb8EsYQ.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.85-Byo_x2hf.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.86-XG7lTN_6.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.87-Lzui2vbK.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.88-CqX6JSgh.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.89-DOzqWPpX.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.9-Btb3bmS6.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.90-BF7RiZjm.woff2 +0 -0
- researchtree/server/static/assets/PretendardVariable.subset.91-Csm0YNoH.woff2 +0 -0
- researchtree/server/static/assets/fraunces-latin-ext-opsz-italic-CGbN9UgK.woff2 +0 -0
- researchtree/server/static/assets/fraunces-latin-ext-opsz-normal-CJcjJNj7.woff2 +0 -0
- researchtree/server/static/assets/fraunces-latin-opsz-italic-lSdLDfvT.woff2 +0 -0
- researchtree/server/static/assets/fraunces-latin-opsz-normal-DihXLNYH.woff2 +0 -0
- researchtree/server/static/assets/fraunces-vietnamese-opsz-italic-DxWqP7Ku.woff2 +0 -0
- researchtree/server/static/assets/fraunces-vietnamese-opsz-normal-Czevyj-6.woff2 +0 -0
- researchtree/server/static/assets/index-CsUFZLi0.js +4041 -0
- researchtree/server/static/assets/index-MSUGCimH.css +1 -0
- researchtree/server/static/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- researchtree/server/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- researchtree/server/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- researchtree/server/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- researchtree/server/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- researchtree/server/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- researchtree/server/static/assets/logo-CrCidBZJ.svg +1 -0
- researchtree/server/static/index.html +17 -0
- researchtree-1.0.0.dist-info/METADATA +447 -0
- researchtree-1.0.0.dist-info/RECORD +134 -0
- researchtree-1.0.0.dist-info/WHEEL +4 -0
- researchtree-1.0.0.dist-info/entry_points.txt +2 -0
- researchtree-1.0.0.dist-info/licenses/LICENSE +201 -0
researchtree/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ResearchTree: record experiments as a tree of Git branches and pull requests, and read it back as code."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("researchtree")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
from .experiment.tracking import conclude, log, set # noqa: E402, A004
|
|
11
|
+
from .memory import from_data, load # noqa: E402
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__", "log", "set", "conclude", "load", "from_data"]
|
researchtree/__main__.py
ADDED
researchtree/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Command line entry point: serve / login / logout / open / memory / release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
from . import __version__, git
|
|
14
|
+
from .github import auth, tokens
|
|
15
|
+
|
|
16
|
+
WEB_VIEWER = "https://darkpyonix.github.io/researchtree/"
|
|
17
|
+
DEFAULT_PORT = 7337
|
|
18
|
+
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _repo_arg(value: str) -> str:
|
|
22
|
+
if not REPO_RE.match(value):
|
|
23
|
+
raise argparse.ArgumentTypeError("owner/name 형식이어야 합니다.")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
28
|
+
from .server import STATIC_DIR, make_server, url_of
|
|
29
|
+
|
|
30
|
+
repo = args.repo or git.origin_repo()
|
|
31
|
+
try:
|
|
32
|
+
httpd, app = make_server(repo, DEFAULT_PORT if args.port is None else args.port)
|
|
33
|
+
except OSError:
|
|
34
|
+
if args.port is not None:
|
|
35
|
+
print(f"포트 {args.port}을(를) 열 수 없습니다.", file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
httpd, app = make_server(repo, 0) # default port busy: pick a free one
|
|
38
|
+
|
|
39
|
+
url = url_of(app) + (f"?repo={urllib.parse.quote(repo, safe='/')}" if repo else "")
|
|
40
|
+
print(f"ResearchTree: {url}")
|
|
41
|
+
if repo:
|
|
42
|
+
print(f"기본 레포: {repo}")
|
|
43
|
+
if not (STATIC_DIR / "index.html").is_file():
|
|
44
|
+
print("주의: 뷰어 빌드 파일(static/)이 없습니다. README의 빌드 방법을 참고하세요.", file=sys.stderr)
|
|
45
|
+
print("종료하려면 Ctrl+C를 누르세요.")
|
|
46
|
+
if not args.no_browser:
|
|
47
|
+
threading.Timer(0.3, webbrowser.open, (url,)).start()
|
|
48
|
+
try:
|
|
49
|
+
httpd.serve_forever()
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
print("\n종료합니다.")
|
|
52
|
+
finally:
|
|
53
|
+
httpd.server_close()
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_login(_: argparse.Namespace) -> int:
|
|
58
|
+
try:
|
|
59
|
+
auth.terminal_login()
|
|
60
|
+
except auth.AuthError as e:
|
|
61
|
+
print(f"로그인 실패: {e}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
print("\n취소했습니다.", file=sys.stderr)
|
|
65
|
+
return 130
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_logout(_: argparse.Namespace) -> int:
|
|
70
|
+
tokens.delete_token()
|
|
71
|
+
print("저장된 토큰을 삭제했습니다.")
|
|
72
|
+
if os.environ.get(tokens.ENV_TOKEN):
|
|
73
|
+
print(f"{tokens.ENV_TOKEN} 환경변수는 그대로 남아 있습니다.")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_open(args: argparse.Namespace) -> int:
|
|
78
|
+
repo = args.repo or git.origin_repo()
|
|
79
|
+
if not repo:
|
|
80
|
+
print("현재 디렉토리에서 GitHub origin remote를 찾지 못했습니다. --repo owner/name을 지정하세요.", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
url = f"{WEB_VIEWER}?repo={urllib.parse.quote(repo, safe='/')}"
|
|
83
|
+
print(url)
|
|
84
|
+
webbrowser.open(url)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_memory(args: argparse.Namespace) -> int:
|
|
89
|
+
"""Print the research memory: the manifest, one island or experiment, alerts, or JSON."""
|
|
90
|
+
import json
|
|
91
|
+
|
|
92
|
+
from . import memory
|
|
93
|
+
from .github import api
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
research = memory.load(args.repo)
|
|
97
|
+
except (ValueError, api.GitHubError) as e:
|
|
98
|
+
print(f"트리를 불러오지 못했습니다: {e}", file=sys.stderr)
|
|
99
|
+
return 1
|
|
100
|
+
if args.json:
|
|
101
|
+
print(json.dumps(research.to_dict(), ensure_ascii=False, indent=2))
|
|
102
|
+
elif args.check:
|
|
103
|
+
for alert in research.check():
|
|
104
|
+
print(alert)
|
|
105
|
+
elif args.name:
|
|
106
|
+
try:
|
|
107
|
+
print(research[args.name].describe())
|
|
108
|
+
except KeyError as e:
|
|
109
|
+
print(e, file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
else:
|
|
112
|
+
print(research.manifest())
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_release(args: argparse.Namespace) -> int:
|
|
117
|
+
"""Cut the next research version: plan by default, archive/tag/push/delete with --yes."""
|
|
118
|
+
from . import memory
|
|
119
|
+
from .experiment import release
|
|
120
|
+
from .github import api
|
|
121
|
+
from .memory.build import VERSION_RE, version_tag
|
|
122
|
+
|
|
123
|
+
g = release.Git()
|
|
124
|
+
if not g.ok("rev-parse", "--git-dir"):
|
|
125
|
+
print("git 저장소 안에서 실행하세요.", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
try:
|
|
128
|
+
research = memory.load(args.repo)
|
|
129
|
+
g.run("fetch", args.remote, "--prune", "--tags")
|
|
130
|
+
exists, merged = release.remote_checks(g, args.remote, research.root_branch)
|
|
131
|
+
steps = release.plan(research, exists=exists, merged=merged)
|
|
132
|
+
version = args.version or release.next_version(research)
|
|
133
|
+
if not VERSION_RE.match(version):
|
|
134
|
+
print(f"버전 이름은 v2, v2.1 같은 형식이어야 합니다: {version}", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
print(release.describe(steps, version_tag(research.root_branch, version)))
|
|
137
|
+
if not args.yes:
|
|
138
|
+
print("\n계획만 보여줬습니다. 실행하려면 --yes를 붙이세요.")
|
|
139
|
+
return 0
|
|
140
|
+
tag = release.execute(research, steps, version, git=g, remote=args.remote)
|
|
141
|
+
except (ValueError, api.GitHubError, release.ReleaseError) as e:
|
|
142
|
+
print(f"버전을 내지 못했습니다: {e}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
print(f"완료: {tag}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
149
|
+
p = argparse.ArgumentParser(prog="researchtree", description="Git 브랜치와 PR로 만드는 실험 트리 뷰어")
|
|
150
|
+
p.add_argument("--version", action="version", version=f"researchtree {__version__}")
|
|
151
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
152
|
+
|
|
153
|
+
s = sub.add_parser("serve", help="로컬에서 뷰어를 띄운다")
|
|
154
|
+
s.add_argument("--repo", type=_repo_arg, help="처음 열 레포 (owner/name)")
|
|
155
|
+
s.add_argument("--port", type=int, help=f"포트 (기본 {DEFAULT_PORT})")
|
|
156
|
+
s.add_argument("--no-browser", action="store_true", help="브라우저를 열지 않는다")
|
|
157
|
+
s.set_defaults(func=cmd_serve)
|
|
158
|
+
|
|
159
|
+
sub.add_parser("login", help="터미널에서 GitHub Device Flow로 로그인한다").set_defaults(func=cmd_login)
|
|
160
|
+
sub.add_parser("logout", help="저장된 토큰을 삭제한다").set_defaults(func=cmd_logout)
|
|
161
|
+
|
|
162
|
+
o = sub.add_parser("open", help="현재 레포의 트리를 중앙 웹 뷰어에서 연다")
|
|
163
|
+
o.add_argument("--repo", type=_repo_arg, help="레포 (owner/name)")
|
|
164
|
+
o.set_defaults(func=cmd_open)
|
|
165
|
+
|
|
166
|
+
m = sub.add_parser("memory", help="PR 트리를 에이전트용 기억으로 출력한다 (요약 → 섬 → 실험)")
|
|
167
|
+
m.add_argument("name", nargs="?", help="버전(v3) 또는 실험 이름을 주면 그 카드만 출력")
|
|
168
|
+
m.add_argument("--repo", type=_repo_arg, help="레포 (owner/name)")
|
|
169
|
+
m.add_argument("--check", action="store_true", help="규칙 검사 결과(경고)만 출력")
|
|
170
|
+
m.add_argument("--json", action="store_true", help="트리 전체를 JSON으로 출력")
|
|
171
|
+
m.set_defaults(func=cmd_memory)
|
|
172
|
+
|
|
173
|
+
r = sub.add_parser("release", help="새 research 버전을 내고 끝난 실험 브랜치를 정리한다 (기본은 계획만 출력)")
|
|
174
|
+
r.add_argument("version", nargs="?", help="버전 이름 (기본: 마지막 버전의 다음 번호)")
|
|
175
|
+
r.add_argument("--repo", type=_repo_arg, help="레포 (owner/name)")
|
|
176
|
+
r.add_argument("--remote", default="origin", help="git remote (기본 origin)")
|
|
177
|
+
r.add_argument("--yes", action="store_true", help="계획대로 실행한다 (보관 머지, 태그, push, 브랜치 삭제)")
|
|
178
|
+
r.set_defaults(func=cmd_release)
|
|
179
|
+
return p
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> int:
|
|
183
|
+
parser = build_parser()
|
|
184
|
+
args = parser.parse_args(argv)
|
|
185
|
+
if not getattr(args, "func", None):
|
|
186
|
+
parser.print_help()
|
|
187
|
+
return 1
|
|
188
|
+
return args.func(args)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Experiment records: the PR body YAML block and the training-script API that writes it."""
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Parse and losslessly rewrite the PR body YAML block. Same rules as `apps/core/src/prbody.ts`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ruamel.yaml import YAML
|
|
12
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
13
|
+
from ruamel.yaml.error import YAMLError
|
|
14
|
+
|
|
15
|
+
STATUSES = ("running", "adopted", "rejected")
|
|
16
|
+
|
|
17
|
+
# First ```yaml fenced block in the body. Opening and closing fences must each be on their own line.
|
|
18
|
+
FENCE_RE = re.compile(r"^```ya?ml[ \t]*\r?\n(.*?)^```[ \t]*(?:\r?\n|$)", re.M | re.S)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrBodyError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ParsedBody:
|
|
27
|
+
meta: dict[str, Any]
|
|
28
|
+
has_block: bool
|
|
29
|
+
yaml: str
|
|
30
|
+
before: str
|
|
31
|
+
after: str
|
|
32
|
+
markdown: str
|
|
33
|
+
warnings: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _yaml() -> YAML:
|
|
37
|
+
y = YAML() # round-trip: keeps comments and key order
|
|
38
|
+
y.preserve_quotes = True
|
|
39
|
+
y.width = 1 << 30 # never fold long lines (TS lineWidth: 0)
|
|
40
|
+
y.indent(mapping=2, sequence=4, offset=2)
|
|
41
|
+
y.allow_unicode = True
|
|
42
|
+
return y
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _locate(body: str) -> tuple[int, int, str] | None:
|
|
46
|
+
m = FENCE_RE.search(body)
|
|
47
|
+
return (m.start(), m.end(), m.group(1)) if m else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _join_markdown(before: str, after: str) -> str:
|
|
51
|
+
b, a = before.strip(), after.strip()
|
|
52
|
+
if b and a:
|
|
53
|
+
return f"{b}\n\n{a}"
|
|
54
|
+
return b or a
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_plain(v: Any) -> Any:
|
|
58
|
+
if isinstance(v, dict):
|
|
59
|
+
return {str(k): _to_plain(x) for k, x in v.items()}
|
|
60
|
+
if isinstance(v, list):
|
|
61
|
+
return [_to_plain(x) for x in v]
|
|
62
|
+
if isinstance(v, bool):
|
|
63
|
+
return bool(v)
|
|
64
|
+
if isinstance(v, int):
|
|
65
|
+
return int(v)
|
|
66
|
+
if isinstance(v, float):
|
|
67
|
+
return float(v)
|
|
68
|
+
if isinstance(v, str):
|
|
69
|
+
return str(v)
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _js_str(v: Any) -> str:
|
|
74
|
+
"""String conversion matching JS `String(v)`."""
|
|
75
|
+
if isinstance(v, bool):
|
|
76
|
+
return "true" if v else "false"
|
|
77
|
+
if isinstance(v, float):
|
|
78
|
+
if math.isfinite(v) and v == int(v):
|
|
79
|
+
return str(int(v))
|
|
80
|
+
return "NaN" if math.isnan(v) else ("Infinity" if v == math.inf else "-Infinity" if v == -math.inf else repr(v))
|
|
81
|
+
if isinstance(v, dict):
|
|
82
|
+
return "[object Object]"
|
|
83
|
+
if isinstance(v, list):
|
|
84
|
+
return ",".join("" if x is None else _js_str(x) for x in v)
|
|
85
|
+
return str(v)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_number(v: Any) -> bool:
|
|
89
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _normalize_meta(raw: dict[str, Any], warnings: list[str]) -> dict[str, Any]:
|
|
93
|
+
"""Type-check known fields. Invalid fields are dropped with a warning."""
|
|
94
|
+
meta = dict(raw)
|
|
95
|
+
|
|
96
|
+
def invalid() -> None:
|
|
97
|
+
if "invalid-field" not in warnings:
|
|
98
|
+
warnings.append("invalid-field")
|
|
99
|
+
|
|
100
|
+
for key in ("parent", "hypothesis", "change", "wandb"):
|
|
101
|
+
if meta.get(key) is not None and not isinstance(meta[key], str):
|
|
102
|
+
meta[key] = _js_str(meta[key])
|
|
103
|
+
if key in meta and meta[key] is None:
|
|
104
|
+
del meta[key]
|
|
105
|
+
|
|
106
|
+
if meta.get("status") is not None:
|
|
107
|
+
if meta["status"] not in STATUSES:
|
|
108
|
+
warnings.append("invalid-status")
|
|
109
|
+
del meta["status"]
|
|
110
|
+
else:
|
|
111
|
+
meta.pop("status", None)
|
|
112
|
+
|
|
113
|
+
if meta.get("metrics") is not None:
|
|
114
|
+
if not isinstance(meta["metrics"], dict):
|
|
115
|
+
invalid()
|
|
116
|
+
del meta["metrics"]
|
|
117
|
+
else:
|
|
118
|
+
clean: dict[str, Any] = {}
|
|
119
|
+
for k, v in meta["metrics"].items():
|
|
120
|
+
if _is_number(v) or isinstance(v, str):
|
|
121
|
+
clean[k] = v
|
|
122
|
+
else:
|
|
123
|
+
invalid()
|
|
124
|
+
meta["metrics"] = clean
|
|
125
|
+
|
|
126
|
+
if meta.get("tags") is not None:
|
|
127
|
+
tags = meta["tags"]
|
|
128
|
+
if isinstance(tags, list):
|
|
129
|
+
meta["tags"] = [_js_str(t) if t is not None else "null" for t in tags]
|
|
130
|
+
elif isinstance(tags, str):
|
|
131
|
+
meta["tags"] = [tags]
|
|
132
|
+
else:
|
|
133
|
+
invalid()
|
|
134
|
+
del meta["tags"]
|
|
135
|
+
|
|
136
|
+
return meta
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse(body: str | None) -> ParsedBody:
|
|
140
|
+
text = body or ""
|
|
141
|
+
loc = _locate(text)
|
|
142
|
+
if not loc:
|
|
143
|
+
return ParsedBody({}, False, "", text, "", text.strip(), ["no-yaml-block"])
|
|
144
|
+
|
|
145
|
+
start, end, raw = loc
|
|
146
|
+
before, after = text[:start], text[end:]
|
|
147
|
+
warnings: list[str] = []
|
|
148
|
+
meta: dict[str, Any] = {}
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
value = _to_plain(_yaml().load(raw))
|
|
152
|
+
except YAMLError:
|
|
153
|
+
warnings.append("yaml-parse-error")
|
|
154
|
+
else:
|
|
155
|
+
if value is None:
|
|
156
|
+
meta = {}
|
|
157
|
+
elif isinstance(value, dict):
|
|
158
|
+
meta = _normalize_meta(value, warnings)
|
|
159
|
+
else:
|
|
160
|
+
warnings.append("yaml-parse-error")
|
|
161
|
+
|
|
162
|
+
if "yaml-parse-error" not in warnings and not meta.get("hypothesis"):
|
|
163
|
+
warnings.append("missing-hypothesis")
|
|
164
|
+
|
|
165
|
+
return ParsedBody(meta, True, raw, before, after, _join_markdown(before, after), warnings)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fence(yaml_text: str) -> str:
|
|
169
|
+
y = yaml_text if yaml_text.endswith("\n") else yaml_text + "\n"
|
|
170
|
+
return "```yaml\n" + y + "```"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _dump(doc: Any) -> str:
|
|
174
|
+
buf = io.StringIO()
|
|
175
|
+
_yaml().dump(doc, buf)
|
|
176
|
+
return buf.getvalue()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def update(body: str | None, patch: dict[str, Any]) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Modify only the YAML block and leave the rest of the body untouched. A None value deletes the field.
|
|
182
|
+
`metrics` is merged into the existing map (set a key to None to delete it).
|
|
183
|
+
A broken YAML block is never overwritten; PrBodyError is raised instead.
|
|
184
|
+
"""
|
|
185
|
+
text = body or ""
|
|
186
|
+
loc = _locate(text)
|
|
187
|
+
|
|
188
|
+
doc: Any = CommentedMap()
|
|
189
|
+
if loc:
|
|
190
|
+
try:
|
|
191
|
+
doc = _yaml().load(loc[2])
|
|
192
|
+
except YAMLError as e:
|
|
193
|
+
raise PrBodyError("PR 본문의 YAML 블록을 파싱할 수 없어 수정하지 않았습니다.") from e
|
|
194
|
+
if doc is None:
|
|
195
|
+
doc = CommentedMap()
|
|
196
|
+
if not isinstance(doc, dict):
|
|
197
|
+
raise PrBodyError("PR 본문의 YAML 블록이 key: value 형식이 아닙니다.")
|
|
198
|
+
|
|
199
|
+
for key, value in patch.items():
|
|
200
|
+
if key == "metrics" and value is not None:
|
|
201
|
+
if not isinstance(doc.get("metrics"), dict):
|
|
202
|
+
doc["metrics"] = CommentedMap()
|
|
203
|
+
metrics = doc["metrics"]
|
|
204
|
+
for mk, mv in value.items():
|
|
205
|
+
if mv is None:
|
|
206
|
+
metrics.pop(mk, None)
|
|
207
|
+
else:
|
|
208
|
+
metrics[mk] = mv
|
|
209
|
+
continue
|
|
210
|
+
if value is None:
|
|
211
|
+
doc.pop(key, None)
|
|
212
|
+
else:
|
|
213
|
+
doc[key] = _to_yaml_node(value)
|
|
214
|
+
|
|
215
|
+
block = _fence(_dump(doc))
|
|
216
|
+
if not loc:
|
|
217
|
+
rest = text.lstrip()
|
|
218
|
+
return f"{block}\n\n{rest}" if rest else f"{block}\n"
|
|
219
|
+
start, end, _ = loc
|
|
220
|
+
trailing = "\n" if re.search(r"\r?\n$", text[start:end]) else ""
|
|
221
|
+
return text[:start] + block + trailing + text[end:]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _to_yaml_node(v: Any) -> Any:
|
|
225
|
+
if isinstance(v, dict) and not isinstance(v, CommentedMap):
|
|
226
|
+
m = CommentedMap()
|
|
227
|
+
for k, x in v.items():
|
|
228
|
+
m[k] = _to_yaml_node(x)
|
|
229
|
+
return m
|
|
230
|
+
if isinstance(v, (list, tuple)) and not isinstance(v, CommentedSeq):
|
|
231
|
+
return CommentedSeq(_to_yaml_node(x) for x in v)
|
|
232
|
+
return v
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def replace_markdown(body: str | None, markdown: str) -> str:
|
|
236
|
+
"""Keep the YAML block and replace only the human-readable markdown."""
|
|
237
|
+
text = body or ""
|
|
238
|
+
loc = _locate(text)
|
|
239
|
+
md = markdown.strip()
|
|
240
|
+
if not loc:
|
|
241
|
+
return md
|
|
242
|
+
block = re.sub(r"\r?\n$", "", text[loc[0] : loc[1]])
|
|
243
|
+
return f"{block}\n\n{md}\n" if md else f"{block}\n"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Headings that mark the conclusion section, in any case: `## 결론` or `## Conclusion(s)`. Part of the
|
|
247
|
+
# PR-body convention shared with the TypeScript implementation.
|
|
248
|
+
CONCLUSION_RE = re.compile(r"^##[ \t]+(결론|conclusions?)[ \t]*\r?\n(.*?)(?=^##[ \t]|\Z)", re.M | re.S | re.I)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def set_conclusion(body: str | None, conclusion: str, heading: str | None = "결론") -> str:
|
|
252
|
+
"""Create or replace the conclusion section. An existing `## 결론` / `## Conclusion` section keeps
|
|
253
|
+
its heading and only its text changes; otherwise a new section with `heading` goes first."""
|
|
254
|
+
md = parse(body).markdown
|
|
255
|
+
m = CONCLUSION_RE.search(md)
|
|
256
|
+
if m:
|
|
257
|
+
nxt = md[: m.start()] + f"## {m.group(1)}\n{conclusion.strip()}\n\n" + md[m.end() :]
|
|
258
|
+
else:
|
|
259
|
+
nxt = f"## {heading or '결론'}\n{conclusion.strip()}\n\n" + md
|
|
260
|
+
return replace_markdown(body, nxt)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_conclusion(body: str | None) -> str | None:
|
|
264
|
+
"""Text of the conclusion section (`## 결론` or `## Conclusion`), or None."""
|
|
265
|
+
m = CONCLUSION_RE.search(parse(body).markdown)
|
|
266
|
+
return m.group(2).strip() if m else None
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Cut a new research version and clean up finished experiment branches (docs/CONVENTIONS.md 1.4).
|
|
2
|
+
|
|
3
|
+
Finished branches (PR merged or closed) are deleted when a version is cut. A branch already in the
|
|
4
|
+
root branch is deleted as is. Any other branch is archived first so its history stays reachable:
|
|
5
|
+
its commits are reverted on the branch (new commits, never a reset), the branch is merged into the
|
|
6
|
+
root branch with --no-ff (a no-op for the code), then deleted. Branches with running descendants
|
|
7
|
+
are kept for a later version: reverting a parent before its child lands would drop code the child
|
|
8
|
+
builds on.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
from ..memory.build import version_tag
|
|
19
|
+
from ..memory.model import Experiment, Research
|
|
20
|
+
|
|
21
|
+
DELETE = "delete" # already in the root branch
|
|
22
|
+
ARCHIVE = "archive" # revert, merge into the root branch, delete
|
|
23
|
+
KEEP = "keep" # has running descendants
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReleaseError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Step:
|
|
32
|
+
action: str
|
|
33
|
+
branch: str
|
|
34
|
+
reason: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def next_version(research: Research) -> str:
|
|
38
|
+
"""The major version after the latest: v3 -> v4, v2.1 -> v3; v1 when there are no versions."""
|
|
39
|
+
names = [v.name for v in research.versions if re.match(r"^v\d+", v.name)]
|
|
40
|
+
if not names:
|
|
41
|
+
return "v1"
|
|
42
|
+
return f"v{max(int(re.match(r'^v(\d+)', n).group(1)) for n in names) + 1}" # type: ignore[union-attr]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def plan(research: Research, *, exists: Callable[[str], bool], merged: Callable[[str], bool]) -> list[Step]:
|
|
46
|
+
"""What to do with each finished experiment branch that still exists on the remote."""
|
|
47
|
+
steps: list[Step] = []
|
|
48
|
+
for e in research.experiments:
|
|
49
|
+
if not e.finished or not exists(e.branch):
|
|
50
|
+
continue
|
|
51
|
+
running = [d.name for d in _island_descendants(e) if not d.finished]
|
|
52
|
+
if running:
|
|
53
|
+
steps.append(Step(KEEP, e.branch, "진행 중인 자손: " + ", ".join(running)))
|
|
54
|
+
elif merged(e.branch):
|
|
55
|
+
steps.append(Step(DELETE, e.branch, f"이미 {research.root_branch}에 포함됨"))
|
|
56
|
+
else:
|
|
57
|
+
steps.append(Step(ARCHIVE, e.branch, f"커밋을 revert한 뒤 {research.root_branch}에 머지해 기록 보존"))
|
|
58
|
+
return steps
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _island_descendants(e: Experiment) -> list[Experiment]:
|
|
62
|
+
"""Experiments that build on this branch's code: its descendants on the same island. A later
|
|
63
|
+
version's island starts from the root branch, not from this branch, so the walk stops there."""
|
|
64
|
+
out: list[Experiment] = []
|
|
65
|
+
stack = [c for c in e.children if isinstance(c, Experiment)]
|
|
66
|
+
while stack:
|
|
67
|
+
n = stack.pop()
|
|
68
|
+
out.append(n)
|
|
69
|
+
stack.extend(c for c in n.children if isinstance(c, Experiment))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Git:
|
|
74
|
+
"""Strict git runner for one working tree: any failure raises ReleaseError."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, cwd: str | None = None) -> None:
|
|
77
|
+
self.cwd = cwd
|
|
78
|
+
|
|
79
|
+
def run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
80
|
+
res = subprocess.run(["git", *args], cwd=self.cwd, capture_output=True, text=True, encoding="utf-8")
|
|
81
|
+
if check and res.returncode != 0:
|
|
82
|
+
raise ReleaseError(f"git {' '.join(args)} 실패: {(res.stderr or res.stdout).strip()}")
|
|
83
|
+
return res
|
|
84
|
+
|
|
85
|
+
def out(self, *args: str) -> str:
|
|
86
|
+
return self.run(*args).stdout.strip()
|
|
87
|
+
|
|
88
|
+
def ok(self, *args: str) -> bool:
|
|
89
|
+
return self.run(*args, check=False).returncode == 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def remote_checks(git: Git, remote: str, root: str) -> tuple[Callable[[str], bool], Callable[[str], bool]]:
|
|
93
|
+
"""`exists(branch)` and `merged(branch)` against the fetched remote-tracking refs."""
|
|
94
|
+
|
|
95
|
+
def exists(branch: str) -> bool:
|
|
96
|
+
return git.ok("rev-parse", "--verify", "--quiet", f"refs/remotes/{remote}/{branch}")
|
|
97
|
+
|
|
98
|
+
def merged(branch: str) -> bool:
|
|
99
|
+
return git.ok("merge-base", "--is-ancestor", f"{remote}/{branch}", f"{remote}/{root}")
|
|
100
|
+
|
|
101
|
+
return exists, merged
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _archive(git: Git, remote: str, root: str, branch: str) -> None:
|
|
105
|
+
git.run("switch", "-C", branch, f"{remote}/{branch}")
|
|
106
|
+
base = git.out("merge-base", root, branch)
|
|
107
|
+
commits = git.out("rev-list", f"{base}..{branch}")
|
|
108
|
+
if commits:
|
|
109
|
+
# One revert commit per experiment commit, newest first. Merge commits or conflicts make
|
|
110
|
+
# that fail; then one commit restores the fork point's content instead (still a revert:
|
|
111
|
+
# nothing is rewritten).
|
|
112
|
+
if not git.ok("revert", "--no-edit", f"{base}..{branch}"):
|
|
113
|
+
git.run("revert", "--abort", check=False)
|
|
114
|
+
git.run("read-tree", "--reset", "-u", base)
|
|
115
|
+
git.run("commit", "--allow-empty", "-m", f"Revert: Undo {branch} back to {base[:7]} for archiving")
|
|
116
|
+
if not git.ok("diff", "--quiet", base, branch):
|
|
117
|
+
raise ReleaseError(f"{branch}: revert 후에도 내용이 갈라진 지점과 다릅니다.")
|
|
118
|
+
git.run("switch", root)
|
|
119
|
+
git.run("merge", "--no-ff", branch, "-m", f"Chore: Archive {branch}")
|
|
120
|
+
if not git.ok("diff", "--quiet", "HEAD^1", "HEAD"):
|
|
121
|
+
raise ReleaseError(f"{branch}: 보관 머지가 {root}의 내용을 바꿨습니다.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def execute(
|
|
125
|
+
research: Research,
|
|
126
|
+
steps: Iterable[Step],
|
|
127
|
+
version: str,
|
|
128
|
+
*,
|
|
129
|
+
git: Git,
|
|
130
|
+
remote: str = "origin",
|
|
131
|
+
log: Callable[[str], None] = print,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Archive, tag `<root>/<version>`, push, delete. Returns the tag. The working tree must be clean."""
|
|
134
|
+
root = research.root_branch
|
|
135
|
+
tag = version_tag(root, version)
|
|
136
|
+
steps = list(steps)
|
|
137
|
+
if git.out("status", "--porcelain"):
|
|
138
|
+
raise ReleaseError("작업 트리가 깨끗하지 않습니다. 커밋하거나 stash한 뒤 다시 실행하세요.")
|
|
139
|
+
if git.ok("rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"):
|
|
140
|
+
raise ReleaseError(f"태그 {tag}가 이미 있습니다.")
|
|
141
|
+
|
|
142
|
+
git.run("switch", root)
|
|
143
|
+
git.run("merge", "--ff-only", f"{remote}/{root}")
|
|
144
|
+
archived = [s.branch for s in steps if s.action == ARCHIVE]
|
|
145
|
+
for branch in archived:
|
|
146
|
+
log(f"보관 머지: {branch}")
|
|
147
|
+
_archive(git, remote, root, branch)
|
|
148
|
+
|
|
149
|
+
git.run("tag", "-a", tag, "-m", f"{root} {version}")
|
|
150
|
+
log(f"태그: {tag}")
|
|
151
|
+
git.run("push", remote, root, *archived, f"refs/tags/{tag}")
|
|
152
|
+
|
|
153
|
+
gone = [s.branch for s in steps if s.action in (DELETE, ARCHIVE)]
|
|
154
|
+
if gone:
|
|
155
|
+
git.run("push", remote, "--delete", *gone)
|
|
156
|
+
for branch in gone:
|
|
157
|
+
if git.ok("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"):
|
|
158
|
+
git.run("branch", "-D", branch)
|
|
159
|
+
log("삭제: " + ", ".join(gone))
|
|
160
|
+
return tag
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def describe(steps: Iterable[Step], version_tag_name: str) -> str:
|
|
164
|
+
steps = list(steps)
|
|
165
|
+
label = {DELETE: "삭제", ARCHIVE: "보관 머지 후 삭제", KEEP: "보류"}
|
|
166
|
+
lines = [f"새 버전: {version_tag_name}"]
|
|
167
|
+
if not steps:
|
|
168
|
+
lines.append("정리할 브랜치가 없습니다.")
|
|
169
|
+
for s in steps:
|
|
170
|
+
lines.append(f"- [{label[s.action]}] {s.branch}: {s.reason}")
|
|
171
|
+
return "\n".join(lines)
|